diff --git a/art.py b/art.py index 6edfb09..afbef4f 100644 --- a/art.py +++ b/art.py @@ -1,5 +1,9 @@ -import os.path, json, time, traceback -import random # import random only so art scripts don't have to +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 @@ -15,22 +19,22 @@ ELEM_STRIDE = 6 UV_STRIDE = 2 * 4 # starting document defaults -DEFAULT_CHARSET = 'c64_petscii' -DEFAULT_PALETTE = 'c64_original' +DEFAULT_CHARSET = "c64_petscii" +DEFAULT_PALETTE = "c64_original" DEFAULT_WIDTH, DEFAULT_HEIGHT = 40, 25 -DEFAULT_ART_FILENAME = 'new' +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' +ART_DIR = "art/" +ART_FILE_EXTENSION = "psci" -THUMBNAIL_CACHE_DIR = 'thumbnails/' +THUMBNAIL_CACHE_DIR = "thumbnails/" -ART_SCRIPT_DIR = 'artscripts/' -SCRIPT_FILE_EXTENSION = 'arsc' +ART_SCRIPT_DIR = "artscripts/" +SCRIPT_FILE_EXTENSION = "arsc" # flip/rotate UV constants UV_NORMAL = 0 @@ -45,25 +49,25 @@ 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_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_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) + 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 @@ -75,7 +79,7 @@ uv_types_reverse = { uv_types[UV_FLIPX]: UV_FLIPX, uv_types[UV_FLIPY]: UV_FLIPY, uv_types[UV_FLIP90]: UV_FLIP90, - uv_types[UV_FLIP270]: UV_FLIP270 + uv_types[UV_FLIP270]: UV_FLIP270, } @@ -84,7 +88,7 @@ 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 @@ -93,17 +97,18 @@ class Art: - 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 + + 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('.%s' % ART_FILE_EXTENSION): - filename += '.%s' % ART_FILE_EXTENSION + if filename and not filename.endswith(".%s" % ART_FILE_EXTENSION): + filename += ".%s" % ART_FILE_EXTENSION self.filename = filename self.app = app # save "time loaded" for menu sorting @@ -113,7 +118,7 @@ class Art: 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_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 @@ -154,15 +159,15 @@ class Art: 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: %s' % self.charset.name) - self.app.log(' palette: %s' % self.palette.name) - self.app.log(' width/height: %s x %s' % (self.width, self.height)) - self.app.log(' frames: %s' % self.frames) - self.app.log(' layers: %s' % self.layers) - + self.app.log("created new document:") + self.app.log(" character set: %s" % self.charset.name) + self.app.log(" palette: %s" % self.palette.name) + self.app.log(" width/height: %s x %s" % (self.width, self.height)) + self.app.log(" frames: %s" % self.frames) + self.app.log(" layers: %s" % self.layers) + def init_layers(self): self.layers = 1 # current layer being edited @@ -170,8 +175,8 @@ class Art: # lists of layer Z values and names self.layers_z = [DEFAULT_LAYER_Z] self.layers_visibility = [True] - self.layer_names = ['Layer 1'] - + self.layer_names = ["Layer 1"] + def init_frames(self): self.frames = 0 # current frame being edited @@ -182,10 +187,10 @@ class Art: 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 @@ -214,16 +219,18 @@ class Art: 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 %s' % str(index)) - + self.app.log("Created new frame at index %s" % 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 + 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 @@ -237,9 +244,11 @@ class Art: 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 %s at frame %s' % (src_frame_index+1, dest_frame_index)) - + self.app.ui.set_active_frame(dest_frame_index - 1) + self.app.log( + "Duplicated frame %s at frame %s" % (src_frame_index + 1, dest_frame_index) + ) + def delete_frame_at(self, index): "Delete frame at given index." self.chars.pop(index) @@ -251,7 +260,7 @@ class Art: 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) @@ -265,7 +274,7 @@ class Art: 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 @@ -275,16 +284,18 @@ class Art: # 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) + 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) - + 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]) @@ -295,7 +306,7 @@ class Art: 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 %s' % self.layer_names[src_index] + new_name = new_name or "Copy of %s" % self.layer_names[src_index] self.layer_names.append(new_name) # rebuild geo with added verts for new layer self.geo_changed = True @@ -304,9 +315,9 @@ class 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 %s' % new_name) + self.app.log("Added new layer %s" % 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 @@ -322,7 +333,7 @@ class Art: 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): @@ -340,7 +351,7 @@ class Art: 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: @@ -348,17 +359,21 @@ class Art: 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.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 %s' % self.charset.name) - + self.app.ui.message_line.post_line( + "Character set changed to %s" % 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: @@ -366,13 +381,15 @@ class Art: self.palette = new_palette self.set_unsaved_changes(True) if log: - self.app.ui.message_line.post_line('Color palette changed to %s' % self.palette.name) - + self.app.ui.message_line.post_line( + "Color palette changed to %s" % 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 @@ -385,28 +402,34 @@ class Art: 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) - + 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]: + 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, + + # 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) @@ -424,6 +447,7 @@ class Art: 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 @@ -435,21 +459,23 @@ class Art: 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_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 @@ -464,7 +490,7 @@ class Art: # 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. @@ -487,10 +513,10 @@ class Art: 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 + 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] @@ -498,13 +524,13 @@ class Art: 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 + 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) @@ -516,29 +542,29 @@ class Art: 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 @@ -549,20 +575,21 @@ class Art: 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 + 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) @@ -572,7 +599,7 @@ class Art: 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 @@ -583,7 +610,7 @@ class Art: 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): @@ -591,7 +618,7 @@ class Art: 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 @@ -601,9 +628,19 @@ class Art: # 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): + + 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. @@ -624,7 +661,7 @@ class Art: 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 :/ @@ -632,13 +669,25 @@ class Art: 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)]) - + 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]: + 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 = { @@ -649,19 +698,25 @@ class Art: UV_ROTATE90: UV_FLIP90, UV_FLIP90: UV_ROTATE90, UV_ROTATE270: UV_FLIP270, - UV_FLIP270: UV_ROTATE270 + 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]: + 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 = { @@ -673,43 +728,51 @@ class Art: UV_ROTATE90: UV_FLIP270, UV_FLIP270: UV_ROTATE90, UV_ROTATE270: UV_FLIP90, - UV_FLIP90: UV_ROTATE270 + 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]: + 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() - + 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 @@ -741,7 +804,7 @@ class Art: 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. @@ -751,34 +814,40 @@ class Art: # 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.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) start_time = time.time() # 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) + 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] } + 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] + 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): @@ -787,33 +856,37 @@ class Art: 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 + 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 + frame["layers"] = layers frames.append(frame) - d['frames'] = frames + 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) + json.dump(d, open(self.filename, "w"), sort_keys=True, indent=1) end_time = time.time() 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 %s' % self.filename) + # self.app.log('saved %s to disk in %.5f seconds' % (self.filename, end_time - start_time)) + self.app.log("saved %s" % 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' + 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' + 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 @@ -824,47 +897,61 @@ class Art: # - 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) + 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] } + 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] + 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]) + 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 + frame["layers"] = layers frames.append(frame) - d['frames'] = frames - json.dump(d, open(self.filename + '2', 'w'), sort_keys=True, indent=None) + 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 %s to disk in %.5f seconds' % (self.filename, end_time - start_time)) - + self.app.log( + "ALT saved %s to disk in %.5f seconds" + % (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 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 @@ -876,7 +963,7 @@ class Art: # 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, @@ -898,15 +985,16 @@ class Art: exec(open(script_filename).read()) # (assume script changed art) self.unsaved_changes = True - logline = 'Executed %s' % script_filename - if log: self.app.log(logline) + logline = "Executed %s" % script_filename + if log: + self.app.log(logline) error = False - except Exception as e: + except Exception: error = True - logline = 'Error executing %s:' % script_filename + logline = "Error executing %s:" % script_filename self.app.log(logline) # skip first 3 lines of callstack before artscript exec - for line in traceback.format_exc().split('\n')[3:]: + for line in traceback.format_exc().split("\n")[3:]: if line.strip(): self.app.log(line.rstrip()) # write "after" state of command and commit @@ -914,24 +1002,26 @@ class Art: 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 not type(script_filename) is str: return None - return self.app.find_filename_path(script_filename, ART_SCRIPT_DIR, - SCRIPT_FILE_EXTENSION) - + 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 %s is already running.' % script_filename) + self.app.log("script %s is already running." % script_filename) return # add to "scripts currently running" list self.scripts.append(script_filename) @@ -939,41 +1029,42 @@ class Art: # 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 not script_filename in self.scripts: + if script_filename not in self.scripts: self.app.log("script %s exists but isn't running." % 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): + 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): + + 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): @@ -982,9 +1073,18 @@ class Art: 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): + + 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. @@ -998,18 +1098,30 @@ class Art: else: x_offset = 0 # never let string drawing go out of bounds - text = text[:self.width - (x+x_offset)] + 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) + 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) + 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) + 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): + + 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 @@ -1028,126 +1140,148 @@ class Art: 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) - + 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): + 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: return - width = d['width'] - height = d['height'] - charset = app.load_charset(d['charset']) + width = d["width"] + height = d["height"] + charset = app.load_charset(d["charset"]) if not charset: - app.log('Character set %s not found!' % d['charset']) + app.log("Character set %s not found!" % d["charset"]) return - palette = app.load_palette(d['palette']) + palette = app.load_palette(d["palette"]) if not palette: - app.log('Palette %s not found!' % d['palette']) + app.log("Palette %s not found!" % 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) + Art.__init__(self, filename, app, charset, palette, width, height) # still loading... self.valid = False if not self.app.override_saved_camera: - cam = d['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'] + 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 %s from disk:' % filename) - self.app.log(' character set: %s' % self.charset.name) - self.app.log(' palette: %s' % self.palette.name) - self.app.log(' width/height: %s x %s' % (self.width, self.height)) - self.app.log(' frames: %s' % self.frames) - self.app.log(' layers: %s' % self.layers) - + self.app.log("Loaded %s from disk:" % filename) + self.app.log(" character set: %s" % self.charset.name) + self.app.log(" palette: %s" % self.palette.name) + self.app.log(" width/height: %s x %s" % (self.width, self.height)) + self.app.log(" frames: %s" % self.frames) + self.app.log(" layers: %s" % self.layers) + def init_layers(self): - frames = self.loaded_data['frames'] + frames = self.loaded_data["frames"] # number of layers should be same for all frames - self.layers = len(frames[0]['layers']) + 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))) + 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 %s' % layer_num)) - active_layer = self.loaded_data.get('active_layer', 0) + self.layer_names.append(layer.get("name", "Layer %s" % 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'] + 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']) + 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']): + 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) + 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 @@ -1158,9 +1292,9 @@ class ArtFromDisk(Art): self.uv_mods.append(uvs) self.uv_maps.append(uv_maps) # set active frame properly - active_frame = self.loaded_data.get('active_frame', 0) + 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 @@ -1171,12 +1305,14 @@ 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 = '%s_Instance%i' % (source.filename, time.time()) + self.filename = "%s_Instance%i" % (source.filename, time.time()) self.app = source.app self.instances = None self.char_changed_frames, self.uv_changed_frames = {}, {} @@ -1190,15 +1326,24 @@ class ArtInstance(Art): 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']: + 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[:] @@ -1225,15 +1370,16 @@ class ArtInstance(Art): 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: diff --git a/art_export.py b/art_export.py index 1aca9c3..92282e8 100644 --- a/art_export.py +++ b/art_export.py @@ -1,31 +1,32 @@ - import traceback from art import ART_DIR + class ArtExporter: - """ Class for exporting an Art into a non-Playscii format. Export logic happens in run_export; exporter authors simply extend this class, override run_export and the class properties below. """ - - format_name = 'ERROR - ArtExporter.format_name' + + format_name = "ERROR - ArtExporter.format_name" "User-visible name for this format, shown in export chooser." format_description = "ERROR - ArtExporter.format_description" "String (can be triple-quoted) describing format, shown in export chooser." - file_extension = '' + file_extension = "" "Extension to give the exported file, sans dot." options_dialog_class = None "UIDialog subclass exposing export options to user." - + def __init__(self, app, out_filename, options={}): self.app = app self.art = self.app.ui.active_art # add file extension to output filename if not present - if self.file_extension and not out_filename.endswith('.%s' % self.file_extension): - out_filename += '.%s' % self.file_extension + if self.file_extension and not out_filename.endswith( + ".%s" % self.file_extension + ): + out_filename += ".%s" % self.file_extension # output filename in documents/art dir if not out_filename.startswith(self.app.documents_dir + ART_DIR): out_filename = self.app.documents_dir + ART_DIR + out_filename @@ -40,15 +41,18 @@ class ArtExporter: if self.run_export(out_filename, options): self.success = True else: - line = '%s failed to export %s, see console for errors' % (self.__class__.__name__, out_filename) + line = "%s failed to export %s, see console for errors" % ( + self.__class__.__name__, + out_filename, + ) self.app.log(line) self.app.ui.message_line.post_line(line, hold_time=10, error=True) except: - for line in traceback.format_exc().split('\n'): + for line in traceback.format_exc().split("\n"): self.app.log(line) # store last used export options for "Export last" self.app.last_export_options = options - + def run_export(self, out_filename, options): """ Contains the actual export logic. Write data based on current art, diff --git a/art_import.py b/art_import.py index 137e9be..d5c9303 100644 --- a/art_import.py +++ b/art_import.py @@ -1,18 +1,18 @@ +import os +import traceback -import os, traceback - -from art import Art, ART_FILE_EXTENSION, DEFAULT_CHARSET, DEFAULT_PALETTE +from art import ART_FILE_EXTENSION, DEFAULT_CHARSET, DEFAULT_PALETTE from ui_file_chooser_dialog import GenericImportChooserDialog + class ArtImporter: - """ Class for creating a new Art from data in non-Playscii format. Import logic happens in run_import; importer authors simply extend this class, override run_import and the class properties below. """ - - format_name = 'ERROR - ArtImporter.format_name' + + format_name = "ERROR - ArtImporter.format_name" "User-visible name for this format, shown in import chooser." format_description = "ERROR - ArtImporter.format_description" "String (can be triple-quoted) describing format, shown in import chooser." @@ -25,20 +25,27 @@ class ArtImporter: """ options_dialog_class = None "UIDialog subclass exposing import options to user." - generic_error = '%s failed to import %s' + generic_error = "%s failed to import %s" # if False (eg bitmap conversion), "Imported successfully" message # won't show on successful creation completes_instantly = True - + def __init__(self, app, in_filename, options={}): self.app = app - new_filename = '%s.%s' % (os.path.splitext(in_filename)[0], - ART_FILE_EXTENSION) + new_filename = "%s.%s" % (os.path.splitext(in_filename)[0], ART_FILE_EXTENSION) self.art = self.app.new_art(new_filename) # use charset and palette of existing art - charset = self.app.ui.active_art.charset if self.app.ui.active_art else self.app.load_charset(DEFAULT_CHARSET) + charset = ( + self.app.ui.active_art.charset + if self.app.ui.active_art + else self.app.load_charset(DEFAULT_CHARSET) + ) self.art.set_charset(charset) - palette = self.app.ui.active_art.palette if self.app.ui.active_art else self.app.load_palette(DEFAULT_PALETTE) + palette = ( + self.app.ui.active_art.palette + if self.app.ui.active_art + else self.app.load_palette(DEFAULT_PALETTE) + ) self.art.set_palette(palette) self.app.set_new_art_for_edit(self.art) self.art.clear_frame_layer(0, 0, 1) @@ -49,7 +56,7 @@ class ArtImporter: if self.run_import(in_filename, options): self.success = True except: - for line in traceback.format_exc().split('\n'): + for line in traceback.format_exc().split("\n"): self.app.log(line) if not self.success: line = self.generic_error % (self.__class__.__name__, in_filename) @@ -66,20 +73,20 @@ class ArtImporter: # adjust for new art size and set it active self.app.ui.adjust_for_art_resize(self.art) self.app.ui.set_active_art(self.art) - + def set_art_charset(self, charset_name): "Convenience function for setting charset by name from run_import." self.art.set_charset_by_name(charset_name) - + def set_art_palette(self, palette_name): "Convenience function for setting palette by name from run_import." self.art.set_palette_by_name(palette_name) - + def resize(self, new_width, new_height): "Convenience function for resizing art from run_import" self.art.resize(new_width, new_height) self.app.ui.adjust_for_art_resize(self.art) - + def run_import(self, in_filename, options): """ Contains the actual import logic. Read input file, set Art diff --git a/audio.py b/audio.py index cf03c5e..6d128a4 100644 --- a/audio.py +++ b/audio.py @@ -1,31 +1,32 @@ - import ctypes + from sdl2 import sdlmixer + class PlayingSound: "represents a currently playing sound" + def __init__(self, filename, channel, game_object, looping=False): self.filename = filename self.channel = channel self.go = game_object self.looping = looping + class AudioLord: - sample_rate = 44100 - + def __init__(self, app): self.app = app # initialize audio - sdlmixer.Mix_Init(sdlmixer.MIX_INIT_OGG|sdlmixer.MIX_INIT_MOD) - sdlmixer.Mix_OpenAudio(self.sample_rate, sdlmixer.MIX_DEFAULT_FORMAT, - 2, 1024) + sdlmixer.Mix_Init(sdlmixer.MIX_INIT_OGG | sdlmixer.MIX_INIT_MOD) + sdlmixer.Mix_OpenAudio(self.sample_rate, sdlmixer.MIX_DEFAULT_FORMAT, 2, 1024) self.reset() # sound callback # retain handle to C callable even though we don't use it directly self.sound_cb = ctypes.CFUNCTYPE(None, ctypes.c_int)(self.channel_finished) sdlmixer.Mix_ChannelFinished(self.sound_cb) - + def channel_finished(self, channel): # remove sound from dicts of playing channels and sounds old_sound = self.playing_channels.pop(channel) @@ -33,7 +34,7 @@ class AudioLord: # remove empty list if self.playing_sounds[old_sound.filename] == []: self.playing_sounds.pop(old_sound.filename) - + def reset(self): self.stop_all_music() self.stop_all_sounds() @@ -44,24 +45,25 @@ class AudioLord: # {channel_number: PlayingSound object} self.playing_channels = {} # handle init case where self.musics doesn't exist yet - if hasattr(self, 'musics'): + if hasattr(self, "musics"): for music in self.musics.values(): sdlmixer.Mix_FreeMusic(music) self.musics = {} - if hasattr(self, 'sounds'): + if hasattr(self, "sounds"): for sound in self.sounds.values(): sdlmixer.Mix_FreeChunk(sound) self.sounds = {} - + def register_sound(self, sound_filename): if sound_filename in self.sounds: return self.sounds[sound_filename] - new_sound = sdlmixer.Mix_LoadWAV(bytes(sound_filename, 'utf-8')) + new_sound = sdlmixer.Mix_LoadWAV(bytes(sound_filename, "utf-8")) self.sounds[sound_filename] = new_sound return new_sound - - def object_play_sound(self, game_object, sound_filename, - loops=0, allow_multiple=False): + + def object_play_sound( + self, game_object, sound_filename, loops=0, allow_multiple=False + ): # TODO: volume param? sdlmixer.MIX_MAX_VOLUME if not specified # bail if same object isn't allowed to play same sound multiple times if not allow_multiple and sound_filename in self.playing_sounds: @@ -71,74 +73,75 @@ class AudioLord: sound = self.register_sound(sound_filename) channel = sdlmixer.Mix_PlayChannel(-1, sound, loops) # add sound to dicts of playing sounds and channels - new_playing_sound = PlayingSound(sound_filename, channel, game_object, - loops == -1) + new_playing_sound = PlayingSound( + sound_filename, channel, game_object, loops == -1 + ) if sound_filename in self.playing_sounds: self.playing_sounds[sound_filename].append(new_playing_sound) else: self.playing_sounds[sound_filename] = [new_playing_sound] self.playing_channels[channel] = new_playing_sound - + def object_stop_sound(self, game_object, sound_filename): - if not sound_filename in self.playing_sounds: + if sound_filename not in self.playing_sounds: return # stop all instances of this sound object might be playing for sound in self.playing_sounds[sound_filename]: if game_object is sound.go: sdlmixer.Mix_HaltChannel(sound.channel) - + def object_stop_all_sounds(self, game_object): sounds_to_stop = [] - for sound_filename,sounds in self.playing_sounds.items(): + for sound_filename, sounds in self.playing_sounds.items(): for sound in sounds: if sound.go is game_object: sounds_to_stop.append(sound_filename) for sound_filename in sounds_to_stop: self.object_stop_sound(game_object, sound_filename) - + def stop_all_sounds(self): sdlmixer.Mix_HaltChannel(-1) - + def set_music(self, music_filename): if music_filename in self.musics: return - new_music = sdlmixer.Mix_LoadMUS(bytes(music_filename, 'utf-8')) + new_music = sdlmixer.Mix_LoadMUS(bytes(music_filename, "utf-8")) self.musics[music_filename] = new_music - + def start_music(self, music_filename, loops=-1): # TODO: fade in support etc music = self.musics[music_filename] sdlmixer.Mix_PlayMusic(music, loops) self.current_music = music_filename - + def pause_music(self): if self.current_music: sdlmixer.Mix_PauseMusic() - + def resume_music(self): if self.current_music: sdlmixer.Mix_ResumeMusic() - + def stop_music(self, music_filename): # TODO: fade out support sdlmixer.Mix_HaltMusic() self.current_music = None - + def is_music_playing(self): return bool(sdlmixer.Mix_PlayingMusic()) - + def resume_music(self): if self.current_music: sdlmixer.Mix_ResumeMusic() - + def stop_all_music(self): sdlmixer.Mix_HaltMusic() self.current_music = None - + def update(self): if self.current_music and not self.is_music_playing(): self.current_music = None - + def destroy(self): self.reset() sdlmixer.Mix_CloseAudio() diff --git a/camera.py b/camera.py index cd50c17..40b014a 100644 --- a/camera.py +++ b/camera.py @@ -1,14 +1,17 @@ import math + import numpy as np + import vector + def clamp(val, lowest, highest): return min(highest, max(lowest, val)) + class Camera: - # good starting values - start_x,start_y = 0,0 + start_x, start_y = 0, 0 start_zoom = 2.5 x_tilt, y_tilt = 0, 0 # pan/zoom speed tuning @@ -28,35 +31,35 @@ class Camera: min_velocity = 0.05 # map extents # starting values only, bounds are generated according to art size - min_x,max_x = -10, 50 - min_y,max_y = -50, 10 + min_x, max_x = -10, 50 + min_y, max_y = -50, 10 use_bounds = True - min_zoom,max_zoom = 1, 1000 + min_zoom, max_zoom = 1, 1000 # matrices -> worldspace renderable vertex shader uniforms fov = 90 near_z = 0.0001 far_z = 100000 - + def __init__(self, app): self.app = app self.reset() self.max_pan_speed = self.base_max_pan_speed - + def reset(self): self.x, self.y = self.start_x, self.start_y self.z = self.start_zoom # store look vectors so world/screen space conversions can refer to it - self.look_x, self.look_y, self.look_z = None,None,None - self.vel_x, self.vel_y, self.vel_z = 0,0,0 + self.look_x, self.look_y, self.look_z = None, None, None + self.vel_x, self.vel_y, self.vel_z = 0, 0, 0 self.mouse_panned, self.moved_this_frame = False, False # GameObject to focus on self.focus_object = None self.calc_projection_matrix() self.calc_view_matrix() - + def calc_projection_matrix(self): self.projection_matrix = self.get_perspective_matrix() - + def calc_view_matrix(self): eye = vector.Vec3(self.x, self.y, self.z) up = vector.Vec3(0, 1, 0) @@ -65,24 +68,23 @@ class Camera: forward = (target - eye).normalize() side = forward.cross(up).normalize() upward = side.cross(forward) - m = [[side.x, upward.x, -forward.x, 0], - [side.y, upward.y, -forward.y, 0], - [side.z, upward.z, -forward.z, 0], - [-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1]] + m = [ + [side.x, upward.x, -forward.x, 0], + [side.y, upward.y, -forward.y, 0], + [side.z, upward.z, -forward.z, 0], + [-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1], + ] self.view_matrix = np.array(m, dtype=np.float32) self.look_x, self.look_y, self.look_z = side, upward, forward - + def get_perspective_matrix(self): zmul = (-2 * self.near_z * self.far_z) / (self.far_z - self.near_z) ymul = 1 / math.tan(self.fov * math.pi / 360) aspect = self.app.window_width / self.app.window_height xmul = ymul / aspect - m = [[xmul, 0, 0, 0], - [ 0, ymul, 0, 0], - [ 0, 0, -1, -1], - [ 0, 0, zmul, 0]] + m = [[xmul, 0, 0, 0], [0, ymul, 0, 0], [0, 0, -1, -1], [0, 0, zmul, 0]] return np.array(m, dtype=np.float32) - + def get_ortho_matrix(self, width=None, height=None): width, height = width or self.app.window_width, height or self.app.window_height m = np.eye(4, 4, dtype=np.float32) @@ -95,12 +97,9 @@ class Camera: wx = -(right + left) / (right - left) wy = -(top + bottom) / (top - bottom) wz = -(self.far_z + self.near_z) / (self.far_z - self.near_z) - m = [[ x, 0, 0, 0], - [ 0, y, 0, 0], - [ 0, 0, z, 0], - [wx, wy, wz, 0]] + m = [[x, 0, 0, 0], [0, y, 0, 0], [0, 0, z, 0], [wx, wy, wz, 0]] return np.array(m, dtype=np.float32) - + def pan(self, dx, dy, keyboard=False): # modify pan speed based on zoom according to a factor m = (self.pan_zoom_increase_factor * self.z) / self.min_zoom @@ -109,7 +108,7 @@ class Camera: # for brevity, app passes in whether user appears to be keyboard editing if keyboard: self.app.keyboard_editing = True - + def zoom(self, dz, keyboard=False, towards_cursor=False): self.vel_z += dz * self.zoom_accel # pan towards cursor while zooming? @@ -119,11 +118,11 @@ class Camera: self.pan(dx, dy, keyboard) if keyboard: self.app.keyboard_editing = True - + def get_current_zoom_pct(self): "returns % of base (1:1) for current camera" return (self.get_base_zoom() / self.z) * 100 - + def get_base_zoom(self): "returns camera Z needed for 1:1 pixel zoom" wh = self.app.window_height @@ -132,10 +131,10 @@ class Camera: if ch == 8: ch = 16 return wh / ch - + def set_to_base_zoom(self): self.z = self.get_base_zoom() - + def zoom_proportional(self, direction): "zooms in or out via increments of 1:1 pixel scales for active art" if not self.app.ui.active_art: @@ -167,7 +166,7 @@ class Camera: break # kill all Z velocity for camera so we don't drift out of 1:1 self.vel_z = 0 - + def find_closest_zoom_extents(self): def corners_on_screen(): art = self.app.ui.active_art @@ -177,12 +176,12 @@ class Camera: x2 = x1 + art.width * art.quad_width y2 = y1 - art.height * art.quad_height right, bot = vector.world_to_screen_normalized(self.app, x2, y2, z) - #print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot)) + # print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot)) # add 1 tile of UI chars to top and bottom margins top_margin = 1 - self.app.ui.menu_bar.art.quad_height bot_margin = -1 + self.app.ui.status_bar.art.quad_height - return left >= -1 and top <= top_margin and \ - right <= 1 and bot >= bot_margin + return left >= -1 and top <= top_margin and right <= 1 and bot >= bot_margin + # zoom out from minimum until all corners are visible self.z = self.min_zoom # recalc view matrix each move so projection stays correct @@ -192,16 +191,24 @@ class Camera: self.zoom_proportional(-1) self.calc_view_matrix() tries += 1 - + def toggle_zoom_extents(self, override=None): art = self.app.ui.active_art if override is not None: art.camera_zoomed_extents = not override if art.camera_zoomed_extents: # restore cached position - self.x, self.y, self.z = art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z + self.x, self.y, self.z = ( + art.non_extents_camera_x, + art.non_extents_camera_y, + art.non_extents_camera_z, + ) else: - art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z = self.x, self.y, self.z + ( + art.non_extents_camera_x, + art.non_extents_camera_y, + art.non_extents_camera_z, + ) = self.x, self.y, self.z # center camera on art self.x = (art.width * art.quad_width) / 2 self.y = -(art.height * art.quad_height) / 2 @@ -209,27 +216,27 @@ class Camera: # kill all camera velocity when snapping self.vel_x, self.vel_y, self.vel_z = 0, 0, 0 art.camera_zoomed_extents = not art.camera_zoomed_extents - + def window_resized(self): self.calc_projection_matrix() - + def set_zoom(self, z): # TODO: set lerp target, clear if keyboard etc call zoom() self.z = z - + def set_loc(self, x, y, z): - self.x, self.y, self.z = x, y, (z or self.z) # z optional - + self.x, self.y, self.z = x, y, (z or self.z) # z optional + def set_loc_from_obj(self, game_object): self.set_loc(game_object.x, game_object.y, game_object.z) - + def set_for_art(self, art): # set limits self.max_x = art.width * art.quad_width self.min_y = -art.height * art.quad_height # use saved pan/zoom self.set_loc(art.camera_x, art.camera_y, art.camera_z) - + def mouse_pan(self, dx, dy): "pan view based on mouse delta" if dx == 0 and dy == 0: @@ -240,12 +247,13 @@ class Camera: self.y += dy / self.mouse_pan_rate * m self.vel_x = self.vel_y = 0 self.mouse_panned = True - + def update(self): # zoom-proportional pan scale is based on art if self.app.ui.active_art: - speed_scale = clamp(self.get_current_zoom_pct(), - self.pan_min_pct, self.pan_max_pct) + speed_scale = clamp( + self.get_current_zoom_pct(), self.pan_min_pct, self.pan_max_pct + ) self.max_pan_speed = self.base_max_pan_speed / (speed_scale / 100) else: self.max_pan_speed = self.base_max_pan_speed @@ -256,7 +264,7 @@ class Camera: # track towards target # TODO: revisit this for better feel later dx, dy = self.focus_object.x - self.x, self.focus_object.y - self.y - l = math.sqrt(dx ** 2 + dy ** 2) + l = math.sqrt(dx**2 + dy**2) if l != 0 and l > 0.1: il = 1 / l dx *= il @@ -269,7 +277,7 @@ class Camera: self.vel_y = clamp(self.vel_y, -self.max_pan_speed, self.max_pan_speed) # apply friction self.vel_x *= 1 - self.pan_friction - self.vel_y *= 1 - self.pan_friction + self.vel_y *= 1 - self.pan_friction if abs(self.vel_x) < self.min_velocity: self.vel_x = 0 if abs(self.vel_y) < self.min_velocity: @@ -296,8 +304,13 @@ class Camera: self.z = clamp(self.z, self.min_zoom, self.max_zoom) # set view matrix from xyz self.calc_view_matrix() - self.moved_this_frame = self.mouse_panned or self.x != self.last_x or self.y != self.last_y or self.z != self.last_z + self.moved_this_frame = ( + self.mouse_panned + or self.x != self.last_x + or self.y != self.last_y + or self.z != self.last_z + ) self.mouse_panned = False - + def log_loc(self): - self.app.log('camera x=%s, y=%s, z=%s' % (self.x, self.y, self.z)) + self.app.log("camera x=%s, y=%s, z=%s" % (self.x, self.y, self.z)) diff --git a/charset.py b/charset.py index 75bdcfe..f41eeed 100644 --- a/charset.py +++ b/charset.py @@ -1,23 +1,28 @@ -import os.path, string, time +import os.path +import string +import time + from PIL import Image from texture import Texture -CHARSET_DIR = 'charsets/' -CHARSET_FILE_EXTENSION = 'char' +CHARSET_DIR = "charsets/" +CHARSET_FILE_EXTENSION = "char" class CharacterSetLord: - # time in ms between checks for hot reload hot_reload_check_interval = 2 * 1000 - + def __init__(self, app): self.app = app self.last_check = 0 - + def check_hot_reload(self): - if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval: + if ( + self.app.get_elapsed_time() - self.last_check + < self.hot_reload_check_interval + ): return self.last_check = self.app.get_elapsed_time() changed = None @@ -28,22 +33,29 @@ class CharacterSetLord: try: success = charset.load_char_data() if success: - self.app.log('CharacterSetLord: success reloading %s' % charset.filename) + self.app.log( + "CharacterSetLord: success reloading %s" % charset.filename + ) else: - self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True) + self.app.log( + "CharacterSetLord: failed reloading %s" % charset.filename, + True, + ) except: - self.app.log('CharacterSetLord: failed reloading %s' % charset.filename, True) + self.app.log( + "CharacterSetLord: failed reloading %s" % charset.filename, True + ) class CharacterSet: - transparent_color = (0, 0, 0) - + def __init__(self, app, src_filename, log): self.init_success = False self.app = app - self.filename = self.app.find_filename_path(src_filename, CHARSET_DIR, - CHARSET_FILE_EXTENSION) + self.filename = self.app.find_filename_path( + src_filename, CHARSET_DIR, CHARSET_FILE_EXTENSION + ) if not self.filename: self.app.log("Couldn't find character set data %s" % self.filename) return @@ -62,19 +74,21 @@ class CharacterSet: self.app.log("loaded charmap '%s' from %s:" % (self.name, self.filename)) self.report() self.init_success = True - + def load_char_data(self): "carries out majority of CharacterSet init, including loading image" - char_data_src = open(self.filename, encoding='utf-8').readlines() + char_data_src = open(self.filename, encoding="utf-8").readlines() # allow comments: discard any line in char data starting with // # (make sure this doesn't muck up legit mapping data) char_data = [] for line in char_data_src: - if not line.startswith('//'): + if not line.startswith("//"): char_data.append(line) # first line = image file # hold off assigning to self.image_filename til we know it's valid - img_filename = self.app.find_filename_path(char_data.pop(0).strip(), CHARSET_DIR, 'png') + img_filename = self.app.find_filename_path( + char_data.pop(0).strip(), CHARSET_DIR, "png" + ) if not img_filename: self.app.log("Couldn't find character set image %s" % self.image_filename) return False @@ -82,14 +96,14 @@ class CharacterSet: # now that we know the image file's name, store its last modified time self.last_image_change = os.path.getmtime(self.image_filename) # second line = character set dimensions - second_line = char_data.pop(0).strip().split(',') + second_line = char_data.pop(0).strip().split(",") self.map_width, self.map_height = int(second_line[0]), int(second_line[1]) self.char_mapping = {} index = 0 for line in char_data: # strip newlines from mapping - for char in line.strip('\r\n'): - if not char in self.char_mapping: + for char in line.strip("\r\n"): + if char not in self.char_mapping: self.char_mapping[char] = index index += 1 if index >= self.map_width * self.map_height: @@ -105,12 +119,12 @@ class CharacterSet: if has_upper and not has_lower: for char in string.ascii_lowercase: # set may not have all letters - if not char.upper() in self.char_mapping: + if char.upper() not in self.char_mapping: continue self.char_mapping[char] = self.char_mapping[char.upper()] elif has_lower and not has_upper: for char in string.ascii_uppercase: - if not char.lower() in self.char_mapping: + if char.lower() not in self.char_mapping: continue self.char_mapping[char] = self.char_mapping[char.lower()] # last valid index a character can be @@ -121,11 +135,11 @@ class CharacterSet: # store base filename for easy comparisons with not-yet-loaded sets self.base_filename = os.path.splitext(os.path.basename(self.filename))[0] return True - + def load_image_data(self): # load and process image img = Image.open(self.image_filename) - img = img.convert('RGBA') + img = img.convert("RGBA") # flip for openGL img = img.transpose(Image.FLIP_TOP_BOTTOM) self.image_width, self.image_height = img.size @@ -143,25 +157,35 @@ class CharacterSet: # flip image data back and save it for later, eg image conversion img = img.transpose(Image.FLIP_TOP_BOTTOM) self.image_data = img - + def set_char_dimensions(self): # store character dimensions and UV size self.char_width = int(self.image_width / self.map_width) self.char_height = int(self.image_height / self.map_height) self.u_width = self.char_width / self.image_width self.v_height = self.char_height / self.image_height - + def report(self): - self.app.log(' source texture %s is %s x %s pixels' % (self.image_filename, self.image_width, self.image_height)) - self.app.log(' char pixel width/height is %s x %s' % (self.char_width, self.char_height)) - self.app.log(' char map width/height is %s x %s' % (self.map_width, self.map_height)) - self.app.log(' last character index: %s' % self.last_index) - + self.app.log( + " source texture %s is %s x %s pixels" + % (self.image_filename, self.image_width, self.image_height) + ) + self.app.log( + " char pixel width/height is %s x %s" % (self.char_width, self.char_height) + ) + self.app.log( + " char map width/height is %s x %s" % (self.map_width, self.map_height) + ) + self.app.log(" last character index: %s" % self.last_index) + def has_updated(self): "return True if source image file has changed since last check" # tolerate bad filenames in data, don't check stamps on nonexistent ones - if not self.image_filename or not os.path.exists(self.filename) or \ - not os.path.exists(self.image_filename): + if ( + not self.image_filename + or not os.path.exists(self.filename) + or not os.path.exists(self.image_filename) + ): return False data_changed = os.path.getmtime(self.filename) > self.last_data_change img_changed = os.path.getmtime(self.image_filename) > self.last_image_change @@ -170,10 +194,10 @@ class CharacterSet: if img_changed: self.last_image_change = time.time() return data_changed or img_changed - + def get_char_index(self, char): return self.char_mapping.get(char, 0) - + def get_solid_pixels_in_char(self, char_index): "Returns # of solid pixels in character at given index" tile_x = int(char_index % self.map_width) diff --git a/collision.py b/collision.py index 70a42a8..40bcb0a 100644 --- a/collision.py +++ b/collision.py @@ -1,8 +1,11 @@ import math from collections import namedtuple -from renderable import TileRenderable -from renderable_line import CircleCollisionRenderable, BoxCollisionRenderable, TileBoxCollisionRenderable +from renderable_line import ( + BoxCollisionRenderable, + CircleCollisionRenderable, + TileBoxCollisionRenderable, +) # collision shape types CST_NONE = 0 @@ -32,11 +35,11 @@ CTG_DYNAMIC = [CT_GENERIC_DYNAMIC, CT_PLAYER] __pdoc__ = {} # named tuples for collision structs that don't merit a class -Contact = namedtuple('Contact', ['overlap', 'timestamp']) -__pdoc__['Contact'] = "Represents a contact between two objects." +Contact = namedtuple("Contact", ["overlap", "timestamp"]) +__pdoc__["Contact"] = "Represents a contact between two objects." -ShapeOverlap = namedtuple('ShapeOverlap', ['x', 'y', 'dist', 'area', 'other']) -__pdoc__['ShapeOverlap'] = "Represents a CollisionShape's overlap with another." +ShapeOverlap = namedtuple("ShapeOverlap", ["x", "y", "dist", "area", "other"]) +__pdoc__["ShapeOverlap"] = "Represents a CollisionShape's overlap with another." class CollisionShape: @@ -44,6 +47,7 @@ class CollisionShape: Abstract class for a shape that can overlap and collide with other shapes. Shapes are part of a Collideable which in turn is part of a GameObject. """ + def resolve_overlaps_with_shapes(self, shapes): "Resolve this shape's overlap(s) with given list of shapes." overlaps = [] @@ -57,11 +61,11 @@ class CollisionShape: return # resolve collisions in order of largest -> smallest overlap overlaps.sort(key=lambda item: item.area, reverse=True) - for i,old_overlap in enumerate(overlaps): + for i, old_overlap in enumerate(overlaps): # resolve first overlap without recalculating overlap = self.get_overlap(old_overlap.other) if i > 0 else overlaps[0] self.resolve_overlap(overlap) - + def resolve_overlap(self, overlap): "Resolve this shape's given overlap." other = overlap.other @@ -97,7 +101,7 @@ class CollisionShape: world.try_object_method(self.go, self.go.started_colliding, [other.go]) if b_started_a: world.try_object_method(other.go, other.go.started_colliding, [self.go]) - + def get_overlapping_static_shapes(self): "Return a list of static shapes that overlap with this shape." overlapping_shapes = [] @@ -118,80 +122,118 @@ class CollisionShape: else: # skip if even bounds don't overlap obj_left, obj_top, obj_right, obj_bottom = obj.get_edges() - if not boxes_overlap(shape_left, shape_top, shape_right, shape_bottom, - obj_left, obj_top, obj_right, obj_bottom): + if not boxes_overlap( + shape_left, + shape_top, + shape_right, + shape_bottom, + obj_left, + obj_top, + obj_right, + obj_bottom, + ): continue - overlapping_shapes += obj.collision.get_shapes_overlapping_box(shape_left, shape_top, shape_right, shape_bottom) + overlapping_shapes += obj.collision.get_shapes_overlapping_box( + shape_left, shape_top, shape_right, shape_bottom + ) return overlapping_shapes class CircleCollisionShape(CollisionShape): "CollisionShape using a circle area." + def __init__(self, loc_x, loc_y, radius, game_object): self.x, self.y = loc_x, loc_y self.radius = radius self.go = game_object - + def get_box(self): "Return world coordinates of our bounds (left, top, right, bottom)" - return self.x - self.radius, self.y - self.radius, self.x + self.radius, self.y + self.radius - + return ( + self.x - self.radius, + self.y - self.radius, + self.x + self.radius, + self.y + self.radius, + ) + def is_point_inside(self, x, y): "Return True if given point is inside this shape." - return (self.x - x) ** 2 + (self.y - y) ** 2 <= self.radius ** 2 - + return (self.x - x) ** 2 + (self.y - y) ** 2 <= self.radius**2 + def overlaps_line(self, x1, y1, x2, y2): "Return True if this circle overlaps given line segment." return circle_overlaps_line(self.x, self.y, self.radius, x1, y1, x2, y2) - + def get_overlap(self, other): "Return ShapeOverlap data for this shape's overlap with given other." if type(other) is CircleCollisionShape: - px, py, pdist1, pdist2 = point_circle_penetration(self.x, self.y, - other.x, other.y, - self.radius + other.radius) + px, py, pdist1, pdist2 = point_circle_penetration( + self.x, self.y, other.x, other.y, self.radius + other.radius + ) elif type(other) is AABBCollisionShape: - px, py, pdist1, pdist2 = circle_box_penetration(self.x, self.y, - other.x, other.y, - self.radius, other.halfwidth, - other.halfheight) + px, py, pdist1, pdist2 = circle_box_penetration( + self.x, + self.y, + other.x, + other.y, + self.radius, + other.halfwidth, + other.halfheight, + ) area = abs(pdist1 * pdist2) if pdist1 < 0 else 0 return ShapeOverlap(x=px, y=py, dist=pdist1, area=area, other=other) class AABBCollisionShape(CollisionShape): "CollisionShape using an axis-aligned bounding box area." + def __init__(self, loc_x, loc_y, halfwidth, halfheight, game_object): self.x, self.y = loc_x, loc_y self.halfwidth, self.halfheight = halfwidth, halfheight self.go = game_object # for CST_TILE objects, lists of tile(s) we cover self.tiles = [] - + def get_box(self): - return self.x - self.halfwidth, self.y - self.halfheight, self.x + self.halfwidth, self.y + self.halfheight - + return ( + self.x - self.halfwidth, + self.y - self.halfheight, + self.x + self.halfwidth, + self.y + self.halfheight, + ) + def is_point_inside(self, x, y): "Return True if given point is inside this shape." return point_in_box(x, y, *self.get_box()) - + def overlaps_line(self, x1, y1, x2, y2): "Return True if this box overlaps given line segment." left, top, right, bottom = self.get_box() return box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2) - + def get_overlap(self, other): "Return ShapeOverlap data for this shape's overlap with given other." if type(other) is AABBCollisionShape: - px, py, pdist1, pdist2 = box_penetration(self.x, self.y, - other.x, other.y, - self.halfwidth, self.halfheight, - other.halfwidth, other.halfheight) + px, py, pdist1, pdist2 = box_penetration( + self.x, + self.y, + other.x, + other.y, + self.halfwidth, + self.halfheight, + other.halfwidth, + other.halfheight, + ) elif type(other) is CircleCollisionShape: - px, py, pdist1, pdist2 = circle_box_penetration(other.x, other.y, - self.x, self.y, - other.radius, self.halfwidth, - self.halfheight) + px, py, pdist1, pdist2 = circle_box_penetration( + other.x, + other.y, + self.x, + self.y, + other.radius, + self.halfwidth, + self.halfheight, + ) # reverse result if we're shape B px, py = -px, -py area = abs(pdist1 * pdist2) if pdist1 < 0 else 0 @@ -200,8 +242,10 @@ class AABBCollisionShape(CollisionShape): class Collideable: "Collision component for GameObjects. Contains a list of shapes." + use_art_offset = False "use game object's art_off_pct values" + def __init__(self, obj): "Create new Collideable for given GameObject." self.go = obj @@ -212,7 +256,7 @@ class Collideable: self.contacts = {} "Dict of contacts with other objects, by object name" self.create_shapes() - + def create_shapes(self): """ Create collision shape(s) appropriate to our game object's @@ -231,7 +275,7 @@ class Collideable: # update renderables once if static if not self.go.is_dynamic(): self.update_renderables() - + def _clear_shapes(self): for r in self.renderables: r.destroy() @@ -240,41 +284,49 @@ class Collideable: self.cl._remove_shape(shape) self.shapes = [] "List of CollisionShapes" - + def _create_circle(self): x = self.go.x + self.go.col_offset_x y = self.go.y + self.go.col_offset_y shape = self.cl._add_circle_shape(x, y, self.go.col_radius, self.go) self.shapes = [shape] self.renderables = [CircleCollisionRenderable(shape)] - + def _create_box(self): - x = self.go.x # + self.go.col_offset_x - y = self.go.y # + self.go.col_offset_y - shape = self.cl._add_box_shape(x, y, - self.go.col_width / 2, - self.go.col_height / 2, - self.go) + x = self.go.x # + self.go.col_offset_x + y = self.go.y # + self.go.col_offset_y + shape = self.cl._add_box_shape( + x, y, self.go.col_width / 2, self.go.col_height / 2, self.go + ) self.shapes = [shape] self.renderables = [BoxCollisionRenderable(shape)] - + def _create_merged_tile_boxes(self): "Create AABB shapes for a CST_TILE object" # generate fewer, larger boxes! frame = self.go.renderable.frame - if not self.go.col_layer_name in self.go.art.layer_names: - self.go.app.dev_log("%s: Couldn't find collision layer with name '%s'" % (self.go.name, self.go.col_layer_name)) + if self.go.col_layer_name not in self.go.art.layer_names: + self.go.app.dev_log( + "%s: Couldn't find collision layer with name '%s'" + % (self.go.name, self.go.col_layer_name) + ) return layer = self.go.art.layer_names.index(self.go.col_layer_name) + # tile is available if it's not empty and not already covered by a shape def tile_available(tile_x, tile_y): - return self.go.art.get_char_index_at(frame, layer, tile_x, tile_y) != 0 and not (tile_x, tile_y) in self.tile_shapes + return ( + self.go.art.get_char_index_at(frame, layer, tile_x, tile_y) != 0 + and (tile_x, tile_y) not in self.tile_shapes + ) + def tile_range_available(start_x, end_x, start_y, end_y): for y in range(start_y, end_y + 1): for x in range(start_x, end_x + 1): if not tile_available(x, y): return False return True + for y in range(self.go.art.height): for x in range(self.go.art.width): if not tile_available(x, y): @@ -286,7 +338,9 @@ class Collideable: end_x += 1 # then fill top to bottom end_y = y - while end_y < self.go.art.height - 1 and tile_range_available(x, end_x, y, end_y + 1): + while end_y < self.go.art.height - 1 and tile_range_available( + x, end_x, y, end_y + 1 + ): end_y += 1 # compute origin and halfsizes of box covering tile range wx1, wy1 = self.go.get_tile_loc(x, y, tile_center=True) @@ -299,8 +353,7 @@ class Collideable: halfheight = (end_y - y) * self.go.art.quad_height halfheight /= 2 halfheight += self.go.art.quad_height / 2 - shape = self.cl._add_box_shape(wx, wy, halfwidth, halfheight, - self.go) + shape = self.cl._add_box_shape(wx, wy, halfwidth, halfheight, self.go) # fill in cell(s) in our tile collision dict, # write list of tiles shape covers to shape.tiles for tile_y in range(y, end_y + 1): @@ -312,26 +365,26 @@ class Collideable: r.update() self.shapes.append(shape) self.renderables.append(r) - + def get_shape_overlapping_point(self, x, y): "Return shape if it's overlapping given point, None if no overlap." tile_x, tile_y = self.go.get_tile_at_point(x, y) return self.tile_shapes.get((tile_x, tile_y), None) - + def get_shapes_overlapping_box(self, left, top, right, bottom): "Return a list of our shapes that overlap given box." shapes = [] tiles = self.go.get_tiles_overlapping_box(left, top, right, bottom) - for (x, y) in tiles: + for x, y in tiles: shape = self.tile_shapes.get((x, y), None) - if shape and not shape in shapes: + if shape and shape not in shapes: shapes.append(shape) return shapes - + def update(self): if self.go and self.go.is_dynamic(): self.update_transform_from_object() - + def update_transform_from_object(self, obj=None): "Snap our shapes to location of given object (if unspecified, our GO)." obj = obj or self.go @@ -341,7 +394,7 @@ class Collideable: for shape in self.shapes: shape.x = obj.x + obj.col_offset_x shape.y = obj.y + obj.col_offset_y - + def set_shape_color(self, shape, new_color): "Set the color of a given shape's debug LineRenderable." try: @@ -351,15 +404,15 @@ class Collideable: self.renderables[shape_index].color = new_color self.renderables[shape_index].build_geo() self.renderables[shape_index].rebind_buffers() - + def update_renderables(self): for r in self.renderables: r.update() - + def render(self): for r in self.renderables: r.render() - + def destroy(self): for r in self.renderables: r.destroy() @@ -373,26 +426,29 @@ class CollisionLord: Collision manager object, tracks Collideables, detects overlaps and resolves collisions. """ + iterations = 7 """ Number of times to resolve collisions per update. Lower at own risk; multi-object collisions require multiple iterations to settle correctly. """ + def __init__(self, world): self.world = world self.ticks = 0 # list of objects processed for collision this frame self.collisions_this_frame = [] self.reset() - + def report(self): - print('%s: %s dynamic shapes, %s static shapes' % (self, - len(self.dynamic_shapes), - len(self.static_shapes))) - + print( + "%s: %s dynamic shapes, %s static shapes" + % (self, len(self.dynamic_shapes), len(self.static_shapes)) + ) + def reset(self): self.dynamic_shapes, self.static_shapes = [], [] - + def _add_circle_shape(self, x, y, radius, game_object): shape = CircleCollisionShape(x, y, radius, game_object) if game_object.is_dynamic(): @@ -400,7 +456,7 @@ class CollisionLord: else: self.static_shapes.append(shape) return shape - + def _add_box_shape(self, x, y, halfwidth, halfheight, game_object): shape = AABBCollisionShape(x, y, halfwidth, halfheight, game_object) if game_object.is_dynamic(): @@ -408,13 +464,13 @@ class CollisionLord: else: self.static_shapes.append(shape) return shape - + def _remove_shape(self, shape): if shape in self.dynamic_shapes: self.dynamic_shapes.remove(shape) elif shape in self.static_shapes: self.static_shapes.remove(shape) - + def update(self): "Resolve overlaps between all relevant world objects." for i in range(self.iterations): @@ -437,19 +493,25 @@ class CollisionLord: # collision handling + def point_in_box(x, y, box_left, box_top, box_right, box_bottom): "Return True if given point lies within box with given corners." return box_left <= x <= box_right and box_bottom <= y <= box_top -def boxes_overlap(left_a, top_a, right_a, bottom_a, - left_b, top_b, right_b, bottom_b): + +def boxes_overlap(left_a, top_a, right_a, bottom_a, left_b, top_b, right_b, bottom_b): "Return True if given boxes A and B overlap." - for (x, y) in ((left_a, top_a), (right_a, top_a), - (right_a, bottom_a), (left_a, bottom_a)): + for x, y in ( + (left_a, top_a), + (right_a, top_a), + (right_a, bottom_a), + (left_a, bottom_a), + ): if left_b <= x <= right_b and bottom_b <= y <= top_b: return True return False + def lines_intersect(x1, y1, x2, y2, x3, y3, x4, y4): "Return True if given lines intersect." denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) @@ -465,6 +527,7 @@ def lines_intersect(x1, y1, x2, y2, x3, y3, x4, y4): ub = numer2 / denom return ua >= 0 and ua <= 1 and ub >= 0 and ub <= 1 + def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2): "Return point on given line that's closest to given point." wx, wy = point_x - x1, point_y - y1 @@ -473,7 +536,7 @@ def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2): if proj <= 0: # line point 1 is closest return x1, y1 - vsq = dir_x ** 2 + dir_y ** 2 + vsq = dir_x**2 + dir_y**2 if proj >= vsq: # line point 2 is closest return x2, y2 @@ -481,25 +544,32 @@ def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2): # closest point is between 1 and 2 return x1 + (proj / vsq) * dir_x, y1 + (proj / vsq) * dir_y + def circle_overlaps_line(circle_x, circle_y, radius, x1, y1, x2, y2): "Return True if given circle overlaps given line." # get closest point on line to circle center - closest_x, closest_y = line_point_closest_to_point(circle_x, circle_y, - x1, y1, x2, y2) + closest_x, closest_y = line_point_closest_to_point( + circle_x, circle_y, x1, y1, x2, y2 + ) dist_x, dist_y = closest_x - circle_x, closest_y - circle_y - return dist_x ** 2 + dist_y ** 2 <= radius ** 2 + return dist_x**2 + dist_y**2 <= radius**2 + def box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2): "Return True if given box overlaps given line." # TODO: determine if this is less efficient than slab method below - if point_in_box(x1, y1, left, top, right, bottom) and \ - point_in_box(x2, y2, left, top, right, bottom): + if point_in_box(x1, y1, left, top, right, bottom) and point_in_box( + x2, y2, left, top, right, bottom + ): return True # check left/top/right/bottoms edges - return lines_intersect(left, top, left, bottom, x1, y1, x2, y2) or \ - lines_intersect(left, top, right, top, x1, y1, x2, y2) or \ - lines_intersect(right, top, right, bottom, x1, y1, x2, y2) or \ - lines_intersect(left, bottom, right, bottom, x1, y1, x2, y2) + return ( + lines_intersect(left, top, left, bottom, x1, y1, x2, y2) + or lines_intersect(left, top, right, top, x1, y1, x2, y2) + or lines_intersect(right, top, right, bottom, x1, y1, x2, y2) + or lines_intersect(left, bottom, right, bottom, x1, y1, x2, y2) + ) + def box_overlaps_ray(left, top, right, bottom, x1, y1, x2, y2): "Return True if given box overlaps given ray." @@ -519,16 +589,18 @@ def box_overlaps_ray(left, top, right, bottom, x1, y1, x2, y2): tmax = min(tmax, max(ty1, ty2)) return tmax >= tmin + def point_circle_penetration(point_x, point_y, circle_x, circle_y, radius): "Return normalized penetration x, y, and distance for given circles." dx, dy = circle_x - point_x, circle_y - point_y - pdist = math.sqrt(dx ** 2 + dy ** 2) + pdist = math.sqrt(dx**2 + dy**2) # point is center of circle, arbitrarily project out in +X if pdist == 0: return 1, 0, -radius, -radius # TODO: calculate other axis of intersection for area? return dx / pdist, dy / pdist, pdist - radius, pdist - radius + def box_penetration(ax, ay, bx, by, ahw, ahh, bhw, bhh): "Return penetration vector and magnitude for given boxes." left_a, right_a = ax - ahw, ax + ahw @@ -553,22 +625,32 @@ def box_penetration(ax, ay, bx, by, ahw, ahh, bhw, bhh): elif dy < 0: return 0, -1, -py, -px -def circle_box_penetration(circle_x, circle_y, box_x, box_y, circle_radius, - box_hw, box_hh): + +def circle_box_penetration( + circle_x, circle_y, box_x, box_y, circle_radius, box_hw, box_hh +): "Return penetration vector and magnitude for given circle and box." box_left, box_right = box_x - box_hw, box_x + box_hw box_top, box_bottom = box_y + box_hh, box_y - box_hh # if circle center inside box, use box-on-box penetration vector + distance if point_in_box(circle_x, circle_y, box_left, box_top, box_right, box_bottom): - return box_penetration(circle_x, circle_y, box_x, box_y, - circle_radius, circle_radius, box_hw, box_hh) + return box_penetration( + circle_x, + circle_y, + box_x, + box_y, + circle_radius, + circle_radius, + box_hw, + box_hh, + ) # find point on AABB edges closest to center of circle # clamp = min(highest, max(lowest, val)) px = min(box_right, max(box_left, circle_x)) py = min(box_top, max(box_bottom, circle_y)) closest_x = circle_x - px closest_y = circle_y - py - d = math.sqrt(closest_x ** 2 + closest_y ** 2) + d = math.sqrt(closest_x**2 + closest_y**2) pdist = circle_radius - d if d == 0: return diff --git a/cursor.py b/cursor.py index 660c41d..8ebf860 100644 --- a/cursor.py +++ b/cursor.py @@ -1,4 +1,6 @@ -import math, ctypes +import ctypes +import math + import numpy as np from OpenGL import GL @@ -24,38 +26,29 @@ OUTSIDE_EDGE_SIZE = 0.2 THICKNESS = 0.1 corner_verts = [ - 0, 0, # A/0 - OUTSIDE_EDGE_SIZE, 0, # B/1 - OUTSIDE_EDGE_SIZE, -THICKNESS, # C/2 - THICKNESS, -THICKNESS, # D/3 - THICKNESS, -OUTSIDE_EDGE_SIZE, # E/4 - 0, -OUTSIDE_EDGE_SIZE # F/5 + 0, + 0, # A/0 + OUTSIDE_EDGE_SIZE, + 0, # B/1 + OUTSIDE_EDGE_SIZE, + -THICKNESS, # C/2 + THICKNESS, + -THICKNESS, # D/3 + THICKNESS, + -OUTSIDE_EDGE_SIZE, # E/4 + 0, + -OUTSIDE_EDGE_SIZE, # F/5 ] # vert indices for the above -corner_elems = [ - 0, 1, 2, - 0, 2, 3, - 0, 3, 4, - 0, 5, 4 -] +corner_elems = [0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 5, 4] # X/Y flip transforms to make all 4 corners # (top left, top right, bottom left, bottom right) -corner_transforms = [ - ( 1, 1), - (-1, 1), - ( 1, -1), - (-1, -1) -] +corner_transforms = [(1, 1), (-1, 1), (1, -1), (-1, -1)] # offsets to translate the 4 corners by -corner_offsets = [ - (0, 0), - (1, 0), - (0, -1), - (1, -1) -] +corner_offsets = [(0, 0), (1, 0), (0, -1), (1, -1)] BASE_COLOR = (0.8, 0.8, 0.8, 1) @@ -63,14 +56,14 @@ BASE_COLOR = (0.8, 0.8, 0.8, 1) # because a static vertex list wouldn't be able to adjust to different # character set aspect ratios. + class Cursor: - - vert_shader_source = 'cursor_v.glsl' - frag_shader_source = 'cursor_f.glsl' + vert_shader_source = "cursor_v.glsl" + frag_shader_source = "cursor_f.glsl" alpha = 1 icon_scale_factor = 4 logg = False - + def __init__(self, app): self.app = app self.x, self.y, self.z = 0, 0, 0 @@ -92,29 +85,40 @@ class Cursor: self.elem_array = np.array(corner_elems, dtype=np.uint32) self.vert_count = int(len(self.elem_array)) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer) - GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes, - self.vert_array, GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self.vert_array.nbytes, + self.vert_array, + GL.GL_STATIC_DRAW, + ) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer) - GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes, - self.elem_array, GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ELEMENT_ARRAY_BUFFER, + self.elem_array.nbytes, + self.elem_array, + GL.GL_STATIC_DRAW, + ) # shader, attributes - self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source) + self.shader = self.app.sl.new_shader( + self.vert_shader_source, self.frag_shader_source + ) # vert positions - self.pos_attrib = self.shader.get_attrib_location('vertPosition') + self.pos_attrib = self.shader.get_attrib_location("vertPosition") GL.glEnableVertexAttribArray(self.pos_attrib) offset = ctypes.c_void_p(0) - GL.glVertexAttribPointer(self.pos_attrib, 2, - GL.GL_FLOAT, GL.GL_FALSE, 0, offset) + GL.glVertexAttribPointer( + self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, offset + ) # uniforms - self.proj_matrix_uniform = self.shader.get_uniform_location('projection') - self.view_matrix_uniform = self.shader.get_uniform_location('view') - self.position_uniform = self.shader.get_uniform_location('objectPosition') - self.scale_uniform = self.shader.get_uniform_location('objectScale') - self.color_uniform = self.shader.get_uniform_location('baseColor') - self.quad_size_uniform = self.shader.get_uniform_location('quadSize') - self.xform_uniform = self.shader.get_uniform_location('vertTransform') - self.offset_uniform = self.shader.get_uniform_location('vertOffset') - self.alpha_uniform = self.shader.get_uniform_location('baseAlpha') + self.proj_matrix_uniform = self.shader.get_uniform_location("projection") + self.view_matrix_uniform = self.shader.get_uniform_location("view") + self.position_uniform = self.shader.get_uniform_location("objectPosition") + self.scale_uniform = self.shader.get_uniform_location("objectScale") + self.color_uniform = self.shader.get_uniform_location("baseColor") + self.quad_size_uniform = self.shader.get_uniform_location("quadSize") + self.xform_uniform = self.shader.get_uniform_location("vertTransform") + self.offset_uniform = self.shader.get_uniform_location("vertOffset") + self.alpha_uniform = self.shader.get_uniform_location("baseAlpha") # finish GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0) @@ -122,11 +126,11 @@ class Cursor: GL.glBindVertexArray(0) # init tool sprite, tool will provide texture when rendered self.tool_sprite = UISpriteRenderable(self.app) - + def clamp_to_active_art(self): self.x = max(0, min(self.x, self.app.ui.active_art.width - 1)) self.y = min(0, max(self.y, -self.app.ui.active_art.height + 1)) - + def keyboard_move(self, delta_x, delta_y): if not self.app.ui.active_art: return @@ -136,11 +140,14 @@ class Cursor: self.moved = True self.app.keyboard_editing = True if self.logg: - self.app.log('Cursor: %s,%s,%s scale %.2f,%.2f' % (self.x, self.y, self.z, self.scale_x, self.scale_y)) - + self.app.log( + "Cursor: %s,%s,%s scale %.2f,%.2f" + % (self.x, self.y, self.z, self.scale_x, self.scale_y) + ) + def set_scale(self, new_scale): self.scale_x = self.scale_y = new_scale - + def get_tile(self): # adjust for brush size size = self.app.ui.selected_tool.brush_size @@ -149,7 +156,7 @@ class Cursor: return int(self.x + size_offset), int(-self.y + size_offset) else: return int(self.x), int(-self.y) - + def center_in_art(self): art = self.app.ui.active_art if not art: @@ -157,20 +164,20 @@ class Cursor: self.x = round(art.width / 2) * art.quad_width self.y = round(-art.height / 2) * art.quad_height self.moved = True - + # !!TODO!! finish this, work in progress def get_tiles_under_drag(self): """ returns list of tuple coordinates of all tiles under cursor's current position AND tiles it's moved over since last update """ - + # TODO: get vector of last to current position, for each tile under # current brush, do line trace along grid towards last point - + # TODO: this works in two out of four diagonals, # swap current and last positions to determine delta? - + if self.last_x <= self.x: x0, y0 = self.last_x, -self.last_y x1, y1 = self.x, -self.y @@ -178,10 +185,10 @@ class Cursor: x0, y0 = self.x, -self.y x1, y1 = self.last_x, -self.last_y tiles = vector.get_tiles_along_line(x0, y0, x1, y1) - print('drag from %s,%s to %s,%s:' % (x0, y0, x1, y1)) + print("drag from %s,%s to %s,%s:" % (x0, y0, x1, y1)) print(tiles) return tiles - + def get_tiles_under_brush(self): """ returns list of tuple coordinates of all tiles under the cursor @ its @@ -194,11 +201,11 @@ class Cursor: for x in range(x_start, x_start + size): tiles.append((x, y)) return tiles - + def undo_preview_edits(self): for edit in self.preview_edits: edit.undo() - + def update_cursor_preview(self): # rebuild list of cursor preview commands if self.app.ui.selected_tool.show_preview: @@ -207,9 +214,12 @@ class Cursor: edit.apply() else: self.preview_edits = [] - + def start_paint(self): - if self.app.ui.console.visible or self.app.ui.popup in self.app.ui.hovered_elements: + if ( + self.app.ui.console.visible + or self.app.ui.popup in self.app.ui.hovered_elements + ): return if self.app.ui.selected_tool is self.app.ui.grab_tool: self.app.ui.grab_tool.grab() @@ -219,11 +229,14 @@ class Cursor: self.current_command.add_command_tiles(self.preview_edits) self.preview_edits = [] self.app.ui.active_art.set_unsaved_changes(True) - #print(self.app.ui.active_art.command_stack) - + # print(self.app.ui.active_art.command_stack) + def finish_paint(self): "invoked by mouse button up and undo" - if self.app.ui.console.visible or self.app.ui.popup in self.app.ui.hovered_elements: + if ( + self.app.ui.console.visible + or self.app.ui.popup in self.app.ui.hovered_elements + ): return # push current command group onto undo stack if not self.current_command: @@ -234,25 +247,27 @@ class Cursor: # tools like rotate produce a different change each time, so update again if self.app.ui.selected_tool.update_preview_after_paint: self.update_cursor_preview() - #print(self.app.ui.active_art.command_stack) - + # print(self.app.ui.active_art.command_stack) + def moved_this_frame(self): - return self.moved or \ - int(self.last_x) != int(self.x) or \ - int(self.last_y) != int(self.y) - + return ( + self.moved + or int(self.last_x) != int(self.x) + or int(self.last_y) != int(self.y) + ) + def reposition_from_mouse(self): - self.x, self.y, _ = vector.screen_to_world(self.app, - self.app.mouse_x, - self.app.mouse_y) - + self.x, self.y, _ = vector.screen_to_world( + self.app, self.app.mouse_x, self.app.mouse_y + ) + def snap_to_tile(self): w, h = self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height char_aspect = w / h # round result for oddly proportioned charsets self.x = round(math.floor(self.x / w) * w) self.y = round(math.ceil(self.y / h) * h * char_aspect) - + def pre_first_update(self): # vector.screen_to_world result will be off because camera hasn't # moved yet, recalc view matrix @@ -261,16 +276,18 @@ class Cursor: self.snap_to_tile() self.update_cursor_preview() self.entered_new_tile() - + def update(self): # save old positions before update self.last_x, self.last_y = self.x, self.y # pulse alpha and scale self.alpha = 0.75 + (math.sin(self.app.get_elapsed_time() / 100) / 2) - #self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5) + # self.scale_x = 1.5 + (math.sin(self.get_elapsed_time() / 100) / 50 - 0.5) mouse_moved = self.app.mouse_dx != 0 or self.app.mouse_dy != 0 # update cursor from mouse if: mouse moved, camera moved w/o keyboard - if mouse_moved or (not self.app.keyboard_editing and self.app.camera.moved_this_frame): + if mouse_moved or ( + not self.app.keyboard_editing and self.app.camera.moved_this_frame + ): # don't let mouse move cursor if text tool input is happening if not self.app.ui.text_tool.input_active: self.reposition_from_mouse() @@ -297,36 +314,50 @@ class Cursor: self.update_cursor_preview() if self.moved_this_frame(): self.entered_new_tile() - + def end_update(self): "called at the end of App.update" self.moved = False - + def entered_new_tile(self): if self.current_command and self.app.ui.selected_tool.paint_while_dragging: # add new tile(s) to current command group self.current_command.add_command_tiles(self.preview_edits) self.app.ui.active_art.set_unsaved_changes(True) self.preview_edits = [] - + def render(self): GL.glUseProgram(self.shader.program) - GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix) - GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix) + GL.glUniformMatrix4fv( + self.proj_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.projection_matrix + ) + GL.glUniformMatrix4fv( + self.view_matrix_uniform, 1, GL.GL_FALSE, self.app.camera.view_matrix + ) GL.glUniform3f(self.position_uniform, self.x, self.y, self.z) GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z) GL.glUniform4fv(self.color_uniform, 1, self.color) - GL.glUniform2f(self.quad_size_uniform, self.app.ui.active_art.quad_width, self.app.ui.active_art.quad_height) + GL.glUniform2f( + self.quad_size_uniform, + self.app.ui.active_art.quad_width, + self.app.ui.active_art.quad_height, + ) GL.glUniform1f(self.alpha_uniform, self.alpha) # VAO vs non-VAO paths if self.app.use_vao: GL.glBindVertexArray(self.vao) else: - attrib = self.shader.get_attrib_location # for brevity + attrib = self.shader.get_attrib_location # for brevity GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer) - GL.glVertexAttribPointer(attrib('vertPosition'), 2, GL.GL_FLOAT, GL.GL_FALSE, 0, - ctypes.c_void_p(0)) - GL.glEnableVertexAttribArray(attrib('vertPosition')) + GL.glVertexAttribPointer( + attrib("vertPosition"), + 2, + GL.GL_FLOAT, + GL.GL_FALSE, + 0, + ctypes.c_void_p(0), + ) + GL.glEnableVertexAttribArray(attrib("vertPosition")) # bind elem array instead of passing it to glDrawElements - latter # sends pyopengl a new array, which is deprecated and breaks on Mac. # thanks Erin Congden! @@ -335,12 +366,13 @@ class Cursor: GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) # draw 4 corners for i in range(4): - tx,ty = corner_transforms[i][0], corner_transforms[i][1] - ox,oy = corner_offsets[i][0], corner_offsets[i][1] + tx, ty = corner_transforms[i][0], corner_transforms[i][1] + ox, oy = corner_offsets[i][0], corner_offsets[i][1] GL.glUniform2f(self.xform_uniform, tx, ty) GL.glUniform2f(self.offset_uniform, ox, oy) - GL.glDrawElements(GL.GL_TRIANGLES, self.vert_count, - GL.GL_UNSIGNED_INT, None) + GL.glDrawElements( + GL.GL_TRIANGLES, self.vert_count, GL.GL_UNSIGNED_INT, None + ) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0) GL.glDisable(GL.GL_BLEND) if self.app.use_vao: diff --git a/edit_command.py b/edit_command.py index 91fb6ea..92ed2fe 100644 --- a/edit_command.py +++ b/edit_command.py @@ -1,9 +1,6 @@ -import time - class EditCommand: - "undo/redo-able representation of an art edit (eg paint, erase) operation" - + def __init__(self, art): self.art = art self.start_time = art.app.get_elapsed_time() @@ -12,7 +9,7 @@ class EditCommand: # this prevents multiple commands operating on the same tile # from stomping each other self.tile_commands = {} - + def get_number_of_commands(self): commands = 0 for frame in self.tile_commands.values(): @@ -21,48 +18,52 @@ class EditCommand: for tile in column.values(): commands += 1 return commands - + def __str__(self): # get unique-ish ID from memory address addr = self.__repr__() - addr = addr[addr.find('0'):-1] - s = 'EditCommand_%s: %s tiles, time %s' % (addr, self.get_number_of_commands(), - self.finish_time) + addr = addr[addr.find("0") : -1] + s = "EditCommand_%s: %s tiles, time %s" % ( + addr, + self.get_number_of_commands(), + self.finish_time, + ) return s - + def add_command_tiles(self, new_command_tiles): for ct in new_command_tiles: # create new tables for frames/layers/columns if not present - if not ct.frame in self.tile_commands: + if ct.frame not in self.tile_commands: self.tile_commands[ct.frame] = {} - if not ct.layer in self.tile_commands[ct.frame]: + if ct.layer not in self.tile_commands[ct.frame]: self.tile_commands[ct.frame][ct.layer] = {} - if not ct.y in self.tile_commands[ct.frame][ct.layer]: + if ct.y not in self.tile_commands[ct.frame][ct.layer]: self.tile_commands[ct.frame][ct.layer][ct.y] = {} # preserve "before" state of any command we overwrite if ct.x in self.tile_commands[ct.frame][ct.layer][ct.y]: old_ct = self.tile_commands[ct.frame][ct.layer][ct.y][ct.x] - ct.set_before(old_ct.b_char, old_ct.b_fg, old_ct.b_bg, - old_ct.b_xform) + ct.set_before(old_ct.b_char, old_ct.b_fg, old_ct.b_bg, old_ct.b_xform) self.tile_commands[ct.frame][ct.layer][ct.y][ct.x] = ct - + def undo_commands_for_tile(self, frame, layer, x, y): # no commands at all yet, maybe if len(self.tile_commands) == 0: return # tile might not have undo commands, eg text entry beyond start region - if not y in self.tile_commands[frame][layer] or \ - not x in self.tile_commands[frame][layer][y]: + if ( + y not in self.tile_commands[frame][layer] + or x not in self.tile_commands[frame][layer][y] + ): return self.tile_commands[frame][layer][y][x].undo() - + def undo(self): for frame in self.tile_commands.values(): for layer in frame.values(): for column in layer.values(): for tile_command in column.values(): tile_command.undo() - + def apply(self): for frame in self.tile_commands.values(): for layer in frame.values(): @@ -72,15 +73,14 @@ class EditCommand: class EntireArtCommand: - """ undo/redo-able representation of a whole-art operation, eg: resize/crop, run art script, add/remove layer, etc """ - + # art arrays to grab - array_types = ['chars', 'fg_colors', 'bg_colors', 'uv_mods'] - + array_types = ["chars", "fg_colors", "bg_colors", "uv_mods"] + def __init__(self, art, origin_x=0, origin_y=0): self.art = art # remember origin of resize command @@ -88,14 +88,14 @@ class EntireArtCommand: self.before_frame = art.active_frame self.before_layer = art.active_layer self.start_time = self.finish_time = art.app.get_elapsed_time() - + def save_tiles(self, before=True): # save copies of tile data lists - prefix = 'b' if before else 'a' + prefix = "b" if before else "a" for atype in self.array_types: # save list as eg "b_chars" for "character data before operation" src_data = getattr(self.art, atype) - var_name = '%s_%s' % (prefix, atype) + var_name = "%s_%s" % (prefix, atype) # deep copy each frame's data, else before == after new_data = [] for frame in src_data: @@ -105,7 +105,7 @@ class EntireArtCommand: self.before_size = (self.art.width, self.art.height) else: self.after_size = (self.art.width, self.art.height) - + def undo(self): # undo might remove frames/layers that were added self.art.set_active_frame(self.before_frame) @@ -114,19 +114,19 @@ class EntireArtCommand: x, y = self.before_size self.art.resize(x, y, self.origin_x, self.origin_y) for atype in self.array_types: - new_data = getattr(self, 'b_' + atype) + new_data = getattr(self, "b_" + atype) setattr(self.art, atype, new_data[:]) if self.before_size != self.after_size: # Art.resize will set geo_changed and mark all frames changed self.art.app.ui.adjust_for_art_resize(self.art) self.art.mark_all_frames_changed() - + def apply(self): if self.before_size != self.after_size: x, y = self.after_size self.art.resize(x, y, self.origin_x, self.origin_y) for atype in self.array_types: - new_data = getattr(self, 'a_' + atype) + new_data = getattr(self, "a_" + atype) setattr(self.art, atype, new_data[:]) if self.before_size != self.after_size: self.art.app.ui.adjust_for_art_resize(self.art) @@ -134,7 +134,6 @@ class EntireArtCommand: class EditCommandTile: - def __init__(self, art): self.art = art self.creation_time = self.art.app.get_elapsed_time() @@ -144,26 +143,40 @@ class EditCommandTile: self.frame = self.layer = self.x = self.y = None self.b_char = self.b_fg = self.b_bg = self.b_xform = None self.a_char = self.a_fg = self.a_bg = self.a_xform = None - + def __str__(self): - s = 'F%s L%s %s,%s @ %.2f: ' % (self.frame, self.layer, str(self.x).rjust(2, '0'), str(self.y).rjust(2, '0'), self.creation_time) - s += 'c%s f%s b%s x%s -> ' % (self.b_char, self.b_fg, self.b_bg, self.b_xform) - s += 'c%s f%s b%s x%s' % (self.a_char, self.a_fg, self.a_bg, self.a_xform) + s = "F%s L%s %s,%s @ %.2f: " % ( + self.frame, + self.layer, + str(self.x).rjust(2, "0"), + str(self.y).rjust(2, "0"), + self.creation_time, + ) + s += "c%s f%s b%s x%s -> " % (self.b_char, self.b_fg, self.b_bg, self.b_xform) + s += "c%s f%s b%s x%s" % (self.a_char, self.a_fg, self.a_bg, self.a_xform) return s - + def __eq__(self, value): - return self.frame == value.frame and self.layer == value.layer and \ - self.x == value.x and self.y == value.y and \ - self.b_char == value.b_char and self.b_fg == value.b_fg and \ - self.b_bg == value.b_bg and self.b_xform == value.b_xform and \ - self.a_char == value.a_char and self.a_fg == value.a_fg and \ - self.a_bg == value.a_bg and self.a_xform == value.a_xform - + return ( + self.frame == value.frame + and self.layer == value.layer + and self.x == value.x + and self.y == value.y + and self.b_char == value.b_char + and self.b_fg == value.b_fg + and self.b_bg == value.b_bg + and self.b_xform == value.b_xform + and self.a_char == value.a_char + and self.a_fg == value.a_fg + and self.a_bg == value.a_bg + and self.a_xform == value.a_xform + ) + def copy(self): "returns a deep copy of this tile command" new_ect = EditCommandTile(self.art) # TODO: old or new timestamp? does it matter? - #new_ect.creation_time = self.art.app.get_elapsed_time() + # new_ect.creation_time = self.art.app.get_elapsed_time() new_ect.creation_time = self.creation_time # copy all properties new_ect.frame, new_ect.layer = self.frame, self.layer @@ -173,22 +186,27 @@ class EditCommandTile: new_ect.a_char, new_ect.a_xform = self.a_char, self.a_xform new_ect.a_fg, new_ect.a_bg = self.a_fg, self.a_bg return new_ect - + def set_tile(self, frame, layer, x, y): self.frame, self.layer = frame, layer self.x, self.y = x, y - + def set_before(self, char, fg, bg, xform): self.b_char, self.b_xform = char, xform self.b_fg, self.b_bg = fg, bg - + def set_after(self, char, fg, bg, xform): self.a_char, self.a_xform = char, xform self.a_fg, self.a_bg = fg, bg - + def is_null(self): - return self.a_char == self.b_char and self.a_fg == self.b_fg and self.a_bg == self.b_bg and self.a_xform == self.b_xform - + return ( + self.a_char == self.b_char + and self.a_fg == self.b_fg + and self.a_bg == self.b_bg + and self.a_xform == self.b_xform + ) + def undo(self): # tile's frame or layer may have been deleted if self.layer > self.art.layers - 1 or self.frame > self.art.frames - 1: @@ -196,37 +214,64 @@ class EditCommandTile: if self.x >= self.art.width or self.y >= self.art.height: return tool = self.art.app.ui.selected_tool - set_all = tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform - self.art.set_tile_at(self.frame, self.layer, self.x, self.y, - self.b_char, self.b_fg, self.b_bg, self.b_xform, set_all) - + set_all = ( + tool.affects_char + and tool.affects_fg_color + and tool.affects_fg_color + and tool.affects_xform + ) + self.art.set_tile_at( + self.frame, + self.layer, + self.x, + self.y, + self.b_char, + self.b_fg, + self.b_bg, + self.b_xform, + set_all, + ) + def apply(self): tool = self.art.app.ui.selected_tool - set_all = tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform - self.art.set_tile_at(self.frame, self.layer, self.x, self.y, - self.a_char, self.a_fg, self.a_bg, self.a_xform, set_all) + set_all = ( + tool.affects_char + and tool.affects_fg_color + and tool.affects_fg_color + and tool.affects_xform + ) + self.art.set_tile_at( + self.frame, + self.layer, + self.x, + self.y, + self.a_char, + self.a_fg, + self.a_bg, + self.a_xform, + set_all, + ) class CommandStack: - def __init__(self, art): self.art = art self.undo_commands, self.redo_commands = [], [] - + def __str__(self): - s = 'stack for %s:\n' % self.art.filename - s += '===\nundo:\n' + s = "stack for %s:\n" % self.art.filename + s += "===\nundo:\n" for cmd in self.undo_commands: - s += str(cmd) + '\n' - s += '\n===\nredo:\n' + s += str(cmd) + "\n" + s += "\n===\nredo:\n" for cmd in self.redo_commands: - s += str(cmd) + '\n' + s += str(cmd) + "\n" return s - + def commit_commands(self, new_commands): self.undo_commands += new_commands[:] self.clear_redo() - + def undo(self): if len(self.undo_commands) == 0: return @@ -235,7 +280,7 @@ class CommandStack: command.undo() self.redo_commands.append(command) self.art.app.cursor.update_cursor_preview() - + def redo(self): if len(self.redo_commands) == 0: return @@ -247,6 +292,6 @@ class CommandStack: # add to end of undo stack self.undo_commands.append(command) self.art.app.cursor.update_cursor_preview() - + def clear_redo(self): self.redo_commands = [] diff --git a/formats/in_ans.py b/formats/in_ans.py index 3fb6f8c..6fb55bc 100644 --- a/formats/in_ans.py +++ b/formats/in_ans.py @@ -1,18 +1,18 @@ - from art_import import ArtImporter DEFAULT_FG, DEFAULT_BG = 7, 0 WIDTH = 80 MAX_LINES = 250 + class ANSImporter(ArtImporter): - format_name = 'ANSI' + format_name = "ANSI" format_description = """ Classic scene format using ANSI standard codes. Assumes 80 columns, DOS character set and EGA palette. """ - allowed_file_extensions = ['ans', 'txt'] - + allowed_file_extensions = ["ans", "txt"] + def get_sequence(self, data): "returns a list of ints from given data ending in a letter" i = 0 @@ -22,28 +22,28 @@ Assumes 80 columns, DOS character set and EGA palette. i += 1 seq.append(data[i]) return seq - + def get_commands_from_sequence(self, seq): "returns command type & commands (separated by semicolon) from sequence" cmds = [] - new_cmd = '' + new_cmd = "" for k in seq[:-1]: if k != 59: new_cmd += chr(k) else: cmds.append(new_cmd) - new_cmd = '' + new_cmd = "" # include last command cmds.append(new_cmd) return chr(seq[-1]), cmds - + def run_import(self, in_filename, options={}): - self.set_art_charset('dos') - self.set_art_palette('ansi') + self.set_art_charset("dos") + self.set_art_palette("ansi") # resize to arbitrary height, crop once we know final line count self.resize(WIDTH, MAX_LINES) self.art.clear_frame_layer(0, 0, DEFAULT_BG + 1) - data = open(in_filename, 'rb').read() + data = open(in_filename, "rb").read() x, y = 0, 0 # cursor save/restore codes position saved_x, saved_y = 0, 0 @@ -57,18 +57,19 @@ Assumes 80 columns, DOS character set and EGA palette. if x >= WIDTH: x = 0 y += 1 - if y > max_y: max_y = y + if y > max_y: + max_y = y # how much we will advance through bytes for next iteration increment = 1 # command sequence - if data[i] == 27 and data[i+1] == 91: + if data[i] == 27 and data[i + 1] == 91: increment += 1 # grab full length of sequence - seq = self.get_sequence(data[i+2:]) + seq = self.get_sequence(data[i + 2 :]) # split sequence into individual commands cmd_type, cmds = self.get_commands_from_sequence(seq) # display control - if cmd_type == 'm': + if cmd_type == "m": # empty command = reset if len(cmds) == 0: fg, bg = DEFAULT_FG, DEFAULT_BG @@ -96,30 +97,33 @@ Assumes 80 columns, DOS character set and EGA palette. # change fg color elif 30 <= code <= 37: fg = code - 30 - if fg_bright: fg += 8 + if fg_bright: + fg += 8 # change bg color elif 40 <= code <= 47: bg = code - 40 - if bg_bright: bg += 8 - #else: print('unhandled display code %s' % code) + if bg_bright: + bg += 8 + # else: print('unhandled display code %s' % code) # cursor up/down/forward/back - elif cmd_type == 'A': + elif cmd_type == "A": y -= int(cmds[0]) if cmds[0] else 1 - elif cmd_type == 'B': + elif cmd_type == "B": y += int(cmds[0]) if cmds[0] else 1 - if y > max_y: max_y = y - elif cmd_type == 'C': + if y > max_y: + max_y = y + elif cmd_type == "C": x += int(cmds[0]) if cmds[0] else 1 - elif cmd_type == 'D': + elif cmd_type == "D": x -= int(cmds[0]) if cmds[0] else 1 # break elif ord(cmd_type) == 26: break # set line wrap (ignore for now) - elif cmd_type == 'h': + elif cmd_type == "h": pass # move cursor to Y,X - elif cmd_type == 'H' or cmd_type == 'f': + elif cmd_type == "H" or cmd_type == "f": if len(cmds) == 0 or len(cmds[0]) == 0: new_y = 0 else: @@ -129,9 +133,10 @@ Assumes 80 columns, DOS character set and EGA palette. else: new_x = int(cmds[1]) - 1 x, y = new_x, new_y - if y > max_y: max_y = y + if y > max_y: + max_y = y # clear line/screen - elif cmd_type == 'J': + elif cmd_type == "J": cmd = int(cmds[0]) if cmds else 0 # 0: clear from cursor to end of screen if cmd == 0: @@ -146,24 +151,26 @@ Assumes 80 columns, DOS character set and EGA palette. x, y = 0, 0 self.art.clear_frame_layer(0, 0, DEFAULT_BG + 1) # save cursor position - elif cmd_type == 's': + elif cmd_type == "s": saved_x, saved_y = x, y # restore cursor position - elif cmd_type == 'u': + elif cmd_type == "u": x, y = saved_x, saved_y - #else: print('unhandled escape code %s' % cmd_type) + # else: print('unhandled escape code %s' % cmd_type) increment += len(seq) # CR + LF - elif data[i] == 13 and data[i+1] == 10: + elif data[i] == 13 and data[i + 1] == 10: increment += 1 x = 0 y += 1 - if y > max_y: max_y = y + if y > max_y: + max_y = y # LF elif data[i] == 10: x = 0 y += 1 - if y > max_y: max_y = y + if y > max_y: + max_y = y # indent elif data[i] == 9: x += 8 diff --git a/formats/in_ata.py b/formats/in_ata.py index a889148..a39a6b8 100644 --- a/formats/in_ata.py +++ b/formats/in_ata.py @@ -1,26 +1,26 @@ - from art_import import ArtImporter # import as white on black for ease of edit + export -DEFAULT_FG, DEFAULT_BG =113, 1 +DEFAULT_FG, DEFAULT_BG = 113, 1 # most ATAs are 40 columns, but some are a couple chars longer and a few are 80! WIDTH, HEIGHT = 80, 40 + class ATAImporter(ArtImporter): - format_name = 'ATASCII' + format_name = "ATASCII" format_description = """ ATARI 8-bit computer version of ASCII. Imports with ATASCII character set and Atari palette. """ - allowed_file_extensions = ['ata'] - + allowed_file_extensions = ["ata"] + def run_import(self, in_filename, options={}): - self.set_art_charset('atari') - self.set_art_palette('atari') + self.set_art_charset("atari") + self.set_art_palette("atari") self.resize(WIDTH, HEIGHT) self.art.clear_frame_layer(0, 0, DEFAULT_BG) # iterate over the bytes - data = open(in_filename, 'rb').read() + data = open(in_filename, "rb").read() i = 0 x, y = 0, 0 while i < len(data): diff --git a/formats/in_bitmap.py b/formats/in_bitmap.py index 0fe480d..b01852b 100644 --- a/formats/in_bitmap.py +++ b/formats/in_bitmap.py @@ -1,25 +1,25 @@ - # bitmap image conversion predates the import/export system so it's a bit weird. # conversion happens over time, so it merely kicks off the process. import os + from PIL import Image -from ui_file_chooser_dialog import ImageFileChooserDialog -from ui_dialog import UIDialog, Field -from ui_art_dialog import ImportOptionsDialog -from image_convert import ImageConverter +from art import DEFAULT_CHARSET, DEFAULT_HEIGHT, DEFAULT_PALETTE, DEFAULT_WIDTH from art_import import ArtImporter +from image_convert import ImageConverter from palette import PaletteFromFile -from art import DEFAULT_CHARSET, DEFAULT_PALETTE, DEFAULT_WIDTH, DEFAULT_HEIGHT +from ui_art_dialog import ImportOptionsDialog +from ui_dialog import Field, UIDialog +from ui_file_chooser_dialog import ImageFileChooserDialog # custom chooser showing image previews, shares parent w/ "palette from image" + class ConvertImageChooserDialog(ImageFileChooserDialog): - - title = 'Convert image' - confirm_caption = 'Choose' - + title = "Convert image" + confirm_caption = "Choose" + def confirm_pressed(self): filename = self.field_texts[0] if not os.path.exists(filename) or not os.path.isfile(filename): @@ -30,28 +30,24 @@ class ConvertImageChooserDialog(ImageFileChooserDialog): dialog_class = self.ui.app.importer.options_dialog_class # tell the dialog which image we chose, store its size w, h = Image.open(filename).size - options = { - 'filename': filename, - 'image_width': w, - 'image_height': h - } + options = {"filename": filename, "image_width": w, "image_height": h} self.ui.open_dialog(dialog_class, options) # custom dialog box providing convert options + class ConvertImageOptionsDialog(ImportOptionsDialog): - - title = 'Convert bitmap image options' - field0_label = 'Color palette:' - field1_label = 'Current palette (%s)' - field2_label = 'From source image; # of colors:' - field3_label = ' ' - field5_label = 'Converted art size:' - field6_label = 'Best fit to current size (%s)' - field7_label = '%% of source image: (%s)' - field8_label = ' ' - field10_label = 'Smooth (bicubic) scale source image' + title = "Convert bitmap image options" + field0_label = "Color palette:" + field1_label = "Current palette (%s)" + field2_label = "From source image; # of colors:" + field3_label = " " + field5_label = "Converted art size:" + field6_label = "Best fit to current size (%s)" + field7_label = "%% of source image: (%s)" + field8_label = " " + field10_label = "Smooth (bicubic) scale source image" radio_groups = [(1, 2), (6, 7)] field_width = UIDialog.default_short_field_width # to get the layout we want, we must specify 0 padding lines and @@ -62,57 +58,61 @@ class ConvertImageOptionsDialog(ImportOptionsDialog): Field(label=field1_label, type=bool, width=0, oneline=True), Field(label=field2_label, type=bool, width=0, oneline=True), Field(label=field3_label, type=int, width=field_width, oneline=True), - Field(label='', type=None, width=0, oneline=True), + Field(label="", type=None, width=0, oneline=True), Field(label=field5_label, type=None, width=0, oneline=True), Field(label=field6_label, type=bool, width=0, oneline=True), Field(label=field7_label, type=bool, width=0, oneline=True), Field(label=field8_label, type=float, width=field_width, oneline=True), - Field(label='', type=None, width=0, oneline=True), + Field(label="", type=None, width=0, oneline=True), Field(label=field10_label, type=bool, width=0, oneline=True), - Field(label='', type=None, width=0, oneline=True) + Field(label="", type=None, width=0, oneline=True), ] - invalid_color_error = 'Palettes must be between 2 and 256 colors.' - invalid_scale_error = 'Scale must be greater than 0.0' + invalid_color_error = "Palettes must be between 2 and 256 colors." + invalid_scale_error = "Scale must be greater than 0.0" # redraw dynamic labels always_redraw_labels = True - + def get_initial_field_text(self, field_number): if field_number == 1: return UIDialog.true_field_text elif field_number == 3: # # of colors from source image - return '64' + return "64" elif field_number == 6: return UIDialog.true_field_text elif field_number == 8: # % of source image size - return '50.0' + return "50.0" elif field_number == 10: - return ' ' - return '' - + return " " + return "" + def get_field_label(self, field_index): label = self.fields[field_index].label # custom label replacements to show palette, possible convert sizes if field_index == 1: - label %= self.ui.active_art.palette.name if self.ui.active_art else DEFAULT_PALETTE + label %= ( + self.ui.active_art.palette.name + if self.ui.active_art + else DEFAULT_PALETTE + ) elif field_index == 6: # can't assume any art is open, use defaults if needed w = self.ui.active_art.width if self.ui.active_art else DEFAULT_WIDTH h = self.ui.active_art.height if self.ui.active_art else DEFAULT_HEIGHT - label %= '%s x %s' % (w, h) + label %= "%s x %s" % (w, h) elif field_index == 7: # scale # might not be valid - valid,_ = self.is_input_valid() + valid, _ = self.is_input_valid() if not valid: - return label % '???' - label %= '%s x %s' % self.get_tile_scale() + return label % "???" + label %= "%s x %s" % self.get_tile_scale() return label - + def get_tile_scale(self): "returns scale in tiles of image dimensions" # filename won't be set just after dialog is created - if not hasattr(self, 'filename'): + if not hasattr(self, "filename"): return 0, 0 scale = float(self.field_texts[8]) / 100 # can't assume any art is open, use defaults if needed @@ -127,65 +127,79 @@ class ConvertImageOptionsDialog(ImportOptionsDialog): width *= scale height *= scale return int(width), int(height) - + def is_input_valid(self): # colors: int between 2 and 256 - try: int(self.field_texts[3]) - except: return False, self.invalid_color_error + try: + int(self.field_texts[3]) + except: + return False, self.invalid_color_error colors = int(self.field_texts[3]) - if colors < 2 or colors > 256: + if colors < 2 or colors > 256: return False, self.invalid_color_error # % scale: >0 float - try: float(self.field_texts[8]) - except: return False, self.invalid_scale_error + try: + float(self.field_texts[8]) + except: + return False, self.invalid_scale_error if float(self.field_texts[8]) <= 0: return False, self.invalid_scale_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return self.dismiss() # compile options for importer options = {} # create new palette from image? if self.field_texts[1].strip(): - options['palette'] = self.ui.active_art.palette.name if self.ui.active_art else DEFAULT_PALETTE + options["palette"] = ( + self.ui.active_art.palette.name + if self.ui.active_art + else DEFAULT_PALETTE + ) else: # create new palette palette_filename = os.path.basename(self.filename) colors = int(self.field_texts[3]) - new_pal = PaletteFromFile(self.ui.app, self.filename, - palette_filename, colors) + new_pal = PaletteFromFile( + self.ui.app, self.filename, palette_filename, colors + ) # palette now loaded and saved to disk - options['palette'] = new_pal.name + options["palette"] = new_pal.name # rescale art? if self.field_texts[6].strip(): - options['art_width'] = self.ui.active_art.width if self.ui.active_art else DEFAULT_WIDTH - options['art_height'] = self.ui.active_art.height if self.ui.active_art else DEFAULT_HEIGHT + options["art_width"] = ( + self.ui.active_art.width if self.ui.active_art else DEFAULT_WIDTH + ) + options["art_height"] = ( + self.ui.active_art.height if self.ui.active_art else DEFAULT_HEIGHT + ) else: # art dimensions = scale% of image dimensions, in tiles - options['art_width'], options['art_height'] = self.get_tile_scale() - options['bicubic_scale'] = bool(self.field_texts[10].strip()) + options["art_width"], options["art_height"] = self.get_tile_scale() + options["bicubic_scale"] = bool(self.field_texts[10].strip()) ImportOptionsDialog.do_import(self.ui.app, self.filename, options) class BitmapImageImporter(ArtImporter): - format_name = 'Bitmap image' + format_name = "Bitmap image" format_description = """ Bitmap image in PNG, JPEG, or BMP format. """ file_chooser_dialog_class = ConvertImageChooserDialog options_dialog_class = ConvertImageOptionsDialog completes_instantly = False - + def run_import(self, in_filename, options={}): # modify self.app.ui.active_art based on options - palette = self.app.load_palette(options['palette']) + palette = self.app.load_palette(options["palette"]) self.art.set_palette(palette) - width, height = options['art_width'], options['art_height'] - self.art.resize(width, height) # Importer.init will adjust UI - bicubic_scale = options['bicubic_scale'] + width, height = options["art_width"], options["art_height"] + self.art.resize(width, height) # Importer.init will adjust UI + bicubic_scale = options["bicubic_scale"] # let ImageConverter do the actual heavy lifting ic = ImageConverter(self.app, in_filename, self.art, bicubic_scale) # early failures: file no longer exists, PIL fails to load and convert image diff --git a/formats/in_bitmap_2color.py b/formats/in_bitmap_2color.py index 8e80a85..cb34a38 100644 --- a/formats/in_bitmap_2color.py +++ b/formats/in_bitmap_2color.py @@ -1,14 +1,17 @@ - import numpy as np +from formats.in_bitmap import ( + BitmapImageImporter, + ConvertImageChooserDialog, + ConvertImageOptionsDialog, +) from image_convert import ImageConverter -from ui_dialog import UIDialog, Field, SkipFieldType -from formats.in_bitmap import BitmapImageImporter, ConvertImageChooserDialog, ConvertImageOptionsDialog +from ui_dialog import Field, SkipFieldType, UIDialog class TwoColorConvertImageOptionsDialog(ConvertImageOptionsDialog): # simplified version of parent options dialog, reusing as much as possible - title = 'Convert 2-color bitmap image options' + title = "Convert 2-color bitmap image options" field5_label = ConvertImageOptionsDialog.field5_label field6_label = ConvertImageOptionsDialog.field6_label field7_label = ConvertImageOptionsDialog.field7_label @@ -16,39 +19,38 @@ class TwoColorConvertImageOptionsDialog(ConvertImageOptionsDialog): field10_label = ConvertImageOptionsDialog.field10_label field_width = ConvertImageOptionsDialog.field_width fields = [ - Field(label='', type=SkipFieldType, width=0, oneline=True), - Field(label='', type=SkipFieldType, width=0, oneline=True), - Field(label='', type=SkipFieldType, width=0, oneline=True), - Field(label='', type=SkipFieldType, width=0, oneline=True), - Field(label='', type=SkipFieldType, width=0, oneline=True), + Field(label="", type=SkipFieldType, width=0, oneline=True), + Field(label="", type=SkipFieldType, width=0, oneline=True), + Field(label="", type=SkipFieldType, width=0, oneline=True), + Field(label="", type=SkipFieldType, width=0, oneline=True), + Field(label="", type=SkipFieldType, width=0, oneline=True), Field(label=field5_label, type=None, width=0, oneline=True), Field(label=field6_label, type=bool, width=0, oneline=True), Field(label=field7_label, type=bool, width=0, oneline=True), Field(label=field8_label, type=float, width=field_width, oneline=True), - Field(label='', type=None, width=0, oneline=True), + Field(label="", type=None, width=0, oneline=True), Field(label=field10_label, type=bool, width=0, oneline=True), - Field(label='', type=None, width=0, oneline=True) + Field(label="", type=None, width=0, oneline=True), ] - + def __init__(self, ui, options): ConvertImageOptionsDialog.__init__(self, ui, options) self.active_field = 6 - + def get_initial_field_text(self, field_number): # alternate defaults - use 1:1 scaling if field_number == 6: - return ' ' + return " " elif field_number == 7: return UIDialog.true_field_text elif field_number == 8: # % of source image size - alternate default - return '100.0' + return "100.0" else: return ConvertImageOptionsDialog.get_initial_field_text(self, field_number) class TwoColorImageConverter(ImageConverter): - def get_color_combos_for_block(self, src_block): colors, counts = np.unique(src_block, False, False, return_counts=True) if len(colors) > 0: @@ -60,7 +62,7 @@ class TwoColorImageConverter(ImageConverter): class TwoColorBitmapImageImporter(BitmapImageImporter): - format_name = '2-color bitmap image' + format_name = "2-color bitmap image" format_description = """ Variation on bitmap image conversion that forces a black and white (1-bit) palette, and doesn't use @@ -69,15 +71,15 @@ fg/bg color swaps. Suitable for plaintext export. file_chooser_dialog_class = ConvertImageChooserDialog options_dialog_class = TwoColorConvertImageOptionsDialog completes_instantly = False - + def run_import(self, in_filename, options={}): # force palette to 1-bit black and white - palette = self.app.load_palette('bw') + palette = self.app.load_palette("bw") self.art.set_palette(palette) - - width, height = options['art_width'], options['art_height'] - self.art.resize(width, height) # Importer.init will adjust UI - bicubic_scale = options['bicubic_scale'] + + width, height = options["art_width"], options["art_height"] + self.art.resize(width, height) # Importer.init will adjust UI + bicubic_scale = options["bicubic_scale"] ic = TwoColorImageConverter(self.app, in_filename, self.art, bicubic_scale) # early failures: file no longer exists, PIL fails to load and convert image if not ic.init_success: diff --git a/formats/in_bitmap_sequence.py b/formats/in_bitmap_sequence.py index 384cd35..394bfe8 100644 --- a/formats/in_bitmap_sequence.py +++ b/formats/in_bitmap_sequence.py @@ -1,22 +1,22 @@ - # "convert folder of images to animation" # heavy lifting still done by ImageConverter, this mainly coordinates # conversion of multiple frames -import os, time +import os +import time -import image_convert import formats.in_bitmap as bm +import image_convert + class ImageSequenceConverter: - def __init__(self, app, image_filenames, art, bicubic_scale): self.init_success = False self.app = app self.start_time = time.time() self.image_filenames = image_filenames # App.update_window_title uses image_filename for titlebar - self.image_filename = '' + self.image_filename = "" # common name of sequence self.image_name = os.path.splitext(self.image_filename)[0] self.art = art @@ -24,7 +24,7 @@ class ImageSequenceConverter: # queue up first frame self.next_image(first=True) self.init_success = True - + def next_image(self, first=False): # pop last image off stack if not first: @@ -36,10 +36,9 @@ class ImageSequenceConverter: # next frame self.art.set_active_frame(self.art.active_frame + 1) try: - self.current_frame_converter = image_convert.ImageConverter(self.app, - self.image_filenames[0], - self.art, - self.bicubic_scale, self) + self.current_frame_converter = image_convert.ImageConverter( + self.app, self.image_filenames[0], self.art, self.bicubic_scale, self + ) except: self.fail() return @@ -49,11 +48,11 @@ class ImageSequenceConverter: self.image_filename = self.image_filenames[0] self.preview_sprite = self.current_frame_converter.preview_sprite self.app.update_window_title() - + def fail(self): - self.app.log('Bad frame %s' % self.image_filenames[0], error=True) + self.app.log("Bad frame %s" % self.image_filenames[0], error=True) self.finish(True) - + def update(self): # create converter for new frame if current one is done, # else update current one @@ -61,53 +60,57 @@ class ImageSequenceConverter: self.next_image() else: self.current_frame_converter.update() - + def finish(self, cancelled=False): time_taken = time.time() - self.start_time - (verb, error) = ('cancelled', True) if cancelled else ('finished', False) - self.app.log('Conversion of image sequence %s %s after %.3f seconds' % (self.image_name, verb, time_taken), error) + (verb, error) = ("cancelled", True) if cancelled else ("finished", False) + self.app.log( + "Conversion of image sequence %s %s after %.3f seconds" + % (self.image_name, verb, time_taken), + error, + ) self.app.converter = None self.app.update_window_title() class ConvertImageSequenceChooserDialog(bm.ConvertImageChooserDialog): - title = 'Convert folder' - confirm_caption = 'Choose First Image' + title = "Convert folder" + confirm_caption = "Choose First Image" class BitmapImageSequenceImporter(bm.BitmapImageImporter): - format_name = 'Bitmap image folder' + format_name = "Bitmap image folder" format_description = """ Converts a folder of Bitmap images (PNG, JPEG, or BMP) into an animation. Dimensions will be based on first image chosen. """ file_chooser_dialog_class = ConvertImageSequenceChooserDialog - #options_dialog_class = bm.ConvertImageOptionsDialog - + # options_dialog_class = bm.ConvertImageOptionsDialog + def run_import(self, in_filename, options={}): - palette = self.app.load_palette(options['palette']) + palette = self.app.load_palette(options["palette"]) self.art.set_palette(palette) - width, height = options['art_width'], options['art_height'] - self.art.resize(width, height) # Importer.init will adjust UI - bicubic_scale = options['bicubic_scale'] + width, height = options["art_width"], options["art_height"] + self.art.resize(width, height) # Importer.init will adjust UI + bicubic_scale = options["bicubic_scale"] # get dir listing with full pathname in_dir = os.path.dirname(in_filename) - in_files = ['%s/%s' % (in_dir, f) for f in os.listdir(in_dir)] + in_files = ["%s/%s" % (in_dir, f) for f in os.listdir(in_dir)] in_files.sort() # assume numeric sequence starts from chosen file - in_files = in_files[in_files.index(in_filename):] + in_files = in_files[in_files.index(in_filename) :] # remove files from end of list if they don't end in a number - while not os.path.splitext(in_files[-1])[0][-1].isdecimal() and \ - len(in_files) > 0: + while ( + not os.path.splitext(in_files[-1])[0][-1].isdecimal() and len(in_files) > 0 + ): in_files.pop() # add frames to art as needed while self.art.frames < len(in_files): self.art.add_frame_to_end(log=False) self.art.set_active_frame(0) # create converter - isc = ImageSequenceConverter(self.app, in_files, self.art, - bicubic_scale) + isc = ImageSequenceConverter(self.app, in_files, self.art, bicubic_scale) # bail on early failure if not isc.init_success: return False diff --git a/formats/in_edscii.py b/formats/in_edscii.py index a90e92e..69fc022 100644 --- a/formats/in_edscii.py +++ b/formats/in_edscii.py @@ -1,76 +1,77 @@ - from art_import import ArtImporter -from ui_dialog import UIDialog, Field from ui_art_dialog import ImportOptionsDialog +from ui_dialog import Field, UIDialog class EDSCIIImportOptionsDialog(ImportOptionsDialog): - title = 'Import EDSCII (legacy format) art' - field0_label = 'Width override (leave 0 to guess):' + title = "Import EDSCII (legacy format) art" + field0_label = "Width override (leave 0 to guess):" field_width = UIDialog.default_short_field_width - fields = [ - Field(label=field0_label, type=int, width=field_width, oneline=False) - ] - invalid_width_error = 'Invalid width override.' - + fields = [Field(label=field0_label, type=int, width=field_width, oneline=False)] + invalid_width_error = "Invalid width override." + def get_initial_field_text(self, field_number): if field_number == 0: - return '0' - return '' - + return "0" + return "" + def is_input_valid(self): # valid widths: any >=0 int - try: int(self.field_texts[0]) - except: return False, self.invalid_width_error + try: + int(self.field_texts[0]) + except: + return False, self.invalid_width_error if int(self.field_texts[0]) < 0: return False, self.invalid_width_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return width = int(self.field_texts[0]) width = width if width > 0 else None - options = {'width_override':width} + options = {"width_override": width} self.dismiss() # self.filename is set in our importer's file_chooser_dialog_class ImportOptionsDialog.do_import(self.ui.app, self.filename, options) class EDSCIIImporter(ArtImporter): - - format_name = 'EDSCII' + format_name = "EDSCII" format_description = """ Binary format for EDSCII, Playscii's predecessor. Assumes single frame, single layer document. Current character set and palette will be used. """ options_dialog_class = EDSCIIImportOptionsDialog - + def run_import(self, in_filename, options={}): - data = open(in_filename, 'rb').read() + data = open(in_filename, "rb").read() # document width = find longest stretch before a \n longest_line = 0 for line in data.splitlines(): if len(line) > longest_line: longest_line = len(line) # user can override assumed document width, needed for a few files - width = options.get('width_override', None) or int(longest_line / 3) + width = options.get("width_override", None) or int(longest_line / 3) # derive height from width # 2-byte line breaks might produce non-int result, cast erases this height = int(len(data) / width / 3) self.art.resize(width, height) + # populate char/color arrays by scanning width-long chunks of file def chunks(l, n): for i in range(0, len(l), n): - yield l[i:i+n] + yield l[i : i + n] + # 3 bytes per tile, +1 for line ending # BUT: files saved in windows may have 2 byte line breaks, try to detect lb_length = 1 lines = chunks(data, (width * 3) + lb_length) for line in lines: - if line[-2] == ord('\r') and line[-1] == ord('\n'): - #self.app.log('EDSCIIImporter: windows-style line breaks detected') + if line[-2] == ord("\r") and line[-1] == ord("\n"): + # self.app.log('EDSCIIImporter: windows-style line breaks detected') lb_length = 2 break # recreate generator after first use @@ -81,8 +82,8 @@ Current character set and palette will be used. while index < len(line) - lb_length: char = line[index] # +1 to color indices; playscii color index 0 = transparent - fg = line[index+1] + 1 - bg = line[index+2] + 1 + fg = line[index + 1] + 1 + bg = line[index + 2] + 1 self.art.set_tile_at(0, 0, x, y, char, fg, bg) index += 3 x += 1 diff --git a/formats/in_endoom.py b/formats/in_endoom.py index 10bda82..3433317 100644 --- a/formats/in_endoom.py +++ b/formats/in_endoom.py @@ -1,14 +1,14 @@ - from art_import import ArtImporter + class EndDoomImporter(ArtImporter): - format_name = 'ENDOOM' + format_name = "ENDOOM" format_description = """ ENDOOM lump file format for Doom engine games. 80x25 DOS ASCII with EGA palette. Background colors can only be EGA colors 0-8. """ - + def run_import(self, in_filename, options={}): """ from http://doomwiki.org/wiki/ENDOOM: @@ -18,17 +18,17 @@ Background colors can only be EGA colors 0-8. second byte = color: bits 0-3 = fg color, bits 4-6 = bg color, bit 7 = blink """ - self.set_art_charset('dos') - self.set_art_palette('ega') + self.set_art_charset("dos") + self.set_art_palette("ega") self.art.resize(80, 25) - data = open(in_filename, 'rb').read(4000) + data = open(in_filename, "rb").read(4000) x, y = 0, 0 - for i,byte in enumerate(data): + for i, byte in enumerate(data): if i % 2 != 0: continue - color_byte = data[i+1] + color_byte = data[i + 1] bits = bin(color_byte)[2:] - bits = bits.rjust(7, '0') + bits = bits.rjust(7, "0") bg_bits = bits[:3] fg_bits = bits[3:] offset = 1 diff --git a/formats/in_txt.py b/formats/in_txt.py index ac7259d..0765bdb 100644 --- a/formats/in_txt.py +++ b/formats/in_txt.py @@ -1,15 +1,14 @@ - from art_import import ArtImporter + class TextImporter(ArtImporter): - - format_name = 'Plain text' + format_name = "Plain text" format_description = """ ASCII art in ordinary text format. Assumes single frame, single layer document. Current character set and palette will be used. """ - + def run_import(self, in_filename, options={}): lines = open(in_filename).readlines() # determine length of longest line diff --git a/formats/out_ans.py b/formats/out_ans.py index 0012505..778f019 100644 --- a/formats/out_ans.py +++ b/formats/out_ans.py @@ -1,41 +1,41 @@ - from art_export import ArtExporter WIDTH = 80 -ENCODING = 'cp1252' # old default -ENCODING = 'us-ascii' # DEBUG -ENCODING = 'latin_1' # DEBUG - seems to handle >128 chars ok? +ENCODING = "cp1252" # old default +ENCODING = "us-ascii" # DEBUG +ENCODING = "latin_1" # DEBUG - seems to handle >128 chars ok? + class ANSExporter(ArtExporter): - format_name = 'ANSI' + format_name = "ANSI" format_description = """ Classic scene format using ANSI standard codes. Assumes 80 columns, DOS character set and EGA palette. Exports active layer of active frame. """ - file_extension = 'ans' - + file_extension = "ans" + def get_display_command(self, fg, bg): "return a display command sequence string for given colors" # reset colors on every tile - s = chr(27) + chr(91) + '0;' + s = chr(27) + chr(91) + "0;" if fg >= 8: - s += '1;' + s += "1;" fg -= 8 if bg >= 8: - s += '5;' + s += "5;" bg -= 8 - s += '%s;' % (fg + 30) - s += '%s' % (bg + 40) - s += 'm' + s += "%s;" % (fg + 30) + s += "%s" % (bg + 40) + s += "m" return s - + def write(self, data): self.outfile.write(data.encode(ENCODING)) - + def run_export(self, out_filename, options): # binary file; encoding into ANSI bytes happens just before write - self.outfile = open(out_filename, 'wb') + self.outfile = open(out_filename, "wb") layer = self.art.active_layer frame = self.art.active_frame for y in range(self.art.height): @@ -57,6 +57,6 @@ Exports active layer of active frame. # special (top row) chars won't display in terminal anyway self.write(chr(0)) # carriage return + line feed - self.outfile.write(b'\r\n') + self.outfile.write(b"\r\n") self.outfile.close() return True diff --git a/formats/out_ata.py b/formats/out_ata.py index 85f77fc..2c95732 100644 --- a/formats/out_ata.py +++ b/formats/out_ata.py @@ -1,19 +1,19 @@ - -from art_export import ArtExporter from art import TileIter +from art_export import ArtExporter + class ANSExporter(ArtExporter): - format_name = 'ATASCII' + format_name = "ATASCII" format_description = """ ATARI 8-bit computer version of ASCII. Assumes ATASCII character set and Atari palette. Any tile with non-black background will be considered inverted. """ - file_extension = 'ata' - + file_extension = "ata" + def run_export(self, out_filename, options): # binary file; encoding into ANSI bytes happens just before write - self.outfile = open(out_filename, 'wb') + self.outfile = open(out_filename, "wb") for frame, layer, x, y in TileIter(self.art): # only read from layer 0 of frame 0 if layer > 0 or frame > 0: diff --git a/formats/out_endoom.py b/formats/out_endoom.py index 7f2c9b6..76a8341 100644 --- a/formats/out_endoom.py +++ b/formats/out_endoom.py @@ -1,20 +1,21 @@ - from art_export import ArtExporter WIDTH, HEIGHT = 80, 25 + class EndDoomExporter(ArtExporter): - format_name = 'ENDOOM' + format_name = "ENDOOM" format_description = """ ENDOOM lump file format for Doom engine games. 80x25 DOS ASCII with EGA palette. Background colors can only be EGA colors 0-8. """ + def run_export(self, out_filename, options): if self.art.width < WIDTH or self.art.height < HEIGHT: self.app.log("ENDOOM export: Art isn't big enough!") return False - outfile = open(out_filename, 'wb') + outfile = open(out_filename, "wb") for y in range(HEIGHT): for x in range(WIDTH): char, fg, bg, xform = self.art.get_tile_at(0, 0, x, y) @@ -26,11 +27,11 @@ Background colors can only be EGA colors 0-8. bg = max(0, bg) char_byte = bytes([char]) outfile.write(char_byte) - fg_bits = bin(fg)[2:].rjust(4, '0') + fg_bits = bin(fg)[2:].rjust(4, "0") # BG color can't be above 8 bg %= 8 - bg_bits = bin(bg)[2:].rjust(3, '0') - color_bits = '0' + bg_bits + fg_bits + bg_bits = bin(bg)[2:].rjust(3, "0") + color_bits = "0" + bg_bits + fg_bits color_byte = int(color_bits, 2) color_byte = bytes([color_byte]) outfile.write(color_byte) diff --git a/formats/out_gif.py b/formats/out_gif.py index 0d53f35..7788196 100644 --- a/formats/out_gif.py +++ b/formats/out_gif.py @@ -1,14 +1,15 @@ - from art_export import ArtExporter from image_export import export_animation + class GIFExporter(ArtExporter): - format_name = 'Animated GIF image' + format_name = "Animated GIF image" format_description = """ Animated GIF of all frames in current document, with transparency and proper frame timings. """ - file_extension = 'gif' + file_extension = "gif" + def run_export(self, out_filename, options): # heavy lifting done by image_export module export_animation(self.app, self.app.ui.active_art, out_filename) diff --git a/formats/out_png.py b/formats/out_png.py index 1dcf0a7..e39169f 100644 --- a/formats/out_png.py +++ b/formats/out_png.py @@ -1,67 +1,70 @@ - from art_export import ArtExporter from image_export import export_still_image -from ui_dialog import UIDialog, Field from ui_art_dialog import ExportOptionsDialog +from ui_dialog import Field, UIDialog DEFAULT_SCALE = 4 DEFAULT_CRT = True + class PNGExportOptionsDialog(ExportOptionsDialog): - title = 'PNG image export options' - field0_label = 'Scale factor (%s pixels)' - field1_label = 'CRT filter' + title = "PNG image export options" + field0_label = "Scale factor (%s pixels)" + field1_label = "CRT filter" fields = [ Field(label=field0_label, type=int, width=6, oneline=False), - Field(label=field1_label, type=bool, width=0, oneline=True) + Field(label=field1_label, type=bool, width=0, oneline=True), ] # redraw dynamic labels always_redraw_labels = True - invalid_scale_error = 'Scale must be greater than 0' - + invalid_scale_error = "Scale must be greater than 0" + def get_initial_field_text(self, field_number): if field_number == 0: return str(DEFAULT_SCALE) elif field_number == 1: - return [' ', UIDialog.true_field_text][DEFAULT_CRT] - + return [" ", UIDialog.true_field_text][DEFAULT_CRT] + def get_field_label(self, field_index): label = self.fields[field_index].label if field_index == 0: - valid,_ = self.is_input_valid() + valid, _ = self.is_input_valid() if not valid: - label %= '???' + label %= "???" else: # calculate exported image size art = self.ui.active_art scale = int(self.field_texts[0]) width = art.charset.char_width * art.width * scale height = art.charset.char_height * art.height * scale - label %= '%s x %s' % (width, height) + label %= "%s x %s" % (width, height) return label - + def is_input_valid(self): # scale factor: >0 int - try: int(self.field_texts[0]) - except: return False, self.invalid_scale_error + try: + int(self.field_texts[0]) + except: + return False, self.invalid_scale_error if int(self.field_texts[0]) <= 0: return False, self.invalid_scale_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return self.dismiss() # compile options for exporter options = { - 'scale': int(self.field_texts[0]), - 'crt': bool(self.field_texts[1].strip()) + "scale": int(self.field_texts[0]), + "crt": bool(self.field_texts[1].strip()), } ExportOptionsDialog.do_export(self.ui.app, self.filename, options) class PNGExporter(ArtExporter): - format_name = 'PNG image' + format_name = "PNG image" format_description = """ PNG format (lossless compression) still image of current frame. Can be exported with or without CRT filter effect. @@ -70,12 +73,15 @@ exported image will be 8-bit with same palette as this Art. Otherwise it will be 32-bit with alpha transparency. If CRT filter is enabled, image will always be 32-bit. """ - file_extension = 'png' + file_extension = "png" options_dialog_class = PNGExportOptionsDialog - + def run_export(self, out_filename, options): # heavy lifting done by image_export module - return export_still_image(self.app, self.app.ui.active_art, - out_filename, - crt=options.get('crt', DEFAULT_CRT), - scale=options.get('scale', DEFAULT_SCALE)) + return export_still_image( + self.app, + self.app.ui.active_art, + out_filename, + crt=options.get("crt", DEFAULT_CRT), + scale=options.get("scale", DEFAULT_SCALE), + ) diff --git a/formats/out_png_set.py b/formats/out_png_set.py index 2ef426c..5b94c9d 100644 --- a/formats/out_png_set.py +++ b/formats/out_png_set.py @@ -1,20 +1,20 @@ - import os from art_export import ArtExporter from image_export import export_still_image -from ui_dialog import UIDialog, Field -from ui_art_dialog import ExportOptionsDialog from renderable import LAYER_VIS_FULL, LAYER_VIS_NONE +from ui_art_dialog import ExportOptionsDialog +from ui_dialog import Field, UIDialog -FILE_EXTENSION = 'png' +FILE_EXTENSION = "png" DEFAULT_SCALE = 1 DEFAULT_CRT = False -def get_full_filename(in_filename, frame, layer_name, - use_frame, use_layer, - forbidden_chars): + +def get_full_filename( + in_filename, frame, layer_name, use_frame, use_layer, forbidden_chars +): "Returns properly mutated filename for given frame/layer data" # strip out path and extension from filename as we mutate it dirname = os.path.dirname(in_filename) @@ -22,62 +22,63 @@ def get_full_filename(in_filename, frame, layer_name, base_filename = os.path.splitext(base_filename)[0] fn = base_filename if use_frame: - fn += '_%s' % (str(frame).rjust(4, '0')) + fn += "_%s" % (str(frame).rjust(4, "0")) if use_layer: - fn += '_%s' % layer_name + fn += "_%s" % layer_name # strip unfriendly chars from output filename - for forbidden_char in ['\\', '/', '*', ':']: - fn = fn.replace(forbidden_char, '') + for forbidden_char in ["\\", "/", "*", ":"]: + fn = fn.replace(forbidden_char, "") # add path and extension for final mutated filename - return '%s/%s.%s' % (dirname, fn, FILE_EXTENSION) + return "%s/%s.%s" % (dirname, fn, FILE_EXTENSION) + class PNGSetExportOptionsDialog(ExportOptionsDialog): - title = 'PNG set export options' - tile_width = 60 # extra width for filename preview - field0_label = 'Scale factor (%s pixels)' - field1_label = 'CRT filter' - field2_label = 'Export frames' - field3_label = 'Export layers' - field4_label = 'First filename (in set of %s):' - field5_label = ' %s' + title = "PNG set export options" + tile_width = 60 # extra width for filename preview + field0_label = "Scale factor (%s pixels)" + field1_label = "CRT filter" + field2_label = "Export frames" + field3_label = "Export layers" + field4_label = "First filename (in set of %s):" + field5_label = " %s" fields = [ Field(label=field0_label, type=int, width=6, oneline=False), Field(label=field1_label, type=bool, width=0, oneline=True), Field(label=field2_label, type=bool, width=0, oneline=True), Field(label=field3_label, type=bool, width=0, oneline=True), Field(label=field4_label, type=None, width=0, oneline=True), - Field(label=field5_label, type=None, width=0, oneline=True) + Field(label=field5_label, type=None, width=0, oneline=True), ] # redraw dynamic labels always_redraw_labels = True - invalid_scale_error = 'Scale must be greater than 0' - + invalid_scale_error = "Scale must be greater than 0" + def get_initial_field_text(self, field_number): art = self.ui.active_art if field_number == 0: return str(DEFAULT_SCALE) elif field_number == 1: - return [' ', UIDialog.true_field_text][DEFAULT_CRT] + return [" ", UIDialog.true_field_text][DEFAULT_CRT] elif field_number == 2: # default false if only one frame - return [' ', UIDialog.true_field_text][art.frames > 1] + return [" ", UIDialog.true_field_text][art.frames > 1] elif field_number == 3: # default false if only one layer - return [' ', UIDialog.true_field_text][art.layers > 1] - + return [" ", UIDialog.true_field_text][art.layers > 1] + def get_field_label(self, field_index): label = self.fields[field_index].label if field_index == 0: - valid,_ = self.is_input_valid() + valid, _ = self.is_input_valid() if not valid: - label %= '???' + label %= "???" else: # calculate exported image size art = self.ui.active_art scale = int(self.field_texts[0]) width = art.charset.char_width * art.width * scale height = art.charset.char_height * art.height * scale - label %= '%s x %s' % (width, height) + label %= "%s x %s" % (width, height) # show how many images exported set will be elif field_index == 4: export_frames = bool(self.field_texts[2].strip()) @@ -90,52 +91,60 @@ class PNGSetExportOptionsDialog(ExportOptionsDialog): elif export_layers: label %= str(art.layers) else: - label %= '1' + label %= "1" # preview frame + layer filename mutations based on current settings elif field_index == 5: export_frames = bool(self.field_texts[2].strip()) export_layers = bool(self.field_texts[3].strip()) art = self.ui.active_art - fn = get_full_filename(self.filename, 0, art.layer_names[0], - export_frames, export_layers, - self.ui.app.forbidden_filename_chars) + fn = get_full_filename( + self.filename, + 0, + art.layer_names[0], + export_frames, + export_layers, + self.ui.app.forbidden_filename_chars, + ) fn = os.path.basename(fn) label %= fn return label - + def is_input_valid(self): # scale factor: >0 int - try: int(self.field_texts[0]) - except: return False, self.invalid_scale_error + try: + int(self.field_texts[0]) + except: + return False, self.invalid_scale_error if int(self.field_texts[0]) <= 0: return False, self.invalid_scale_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return self.dismiss() # compile options for importer options = { - 'scale': int(self.field_texts[0]), - 'crt': bool(self.field_texts[1].strip()), - 'frames': bool(self.field_texts[2].strip()), - 'layers': bool(self.field_texts[3].strip()) + "scale": int(self.field_texts[0]), + "crt": bool(self.field_texts[1].strip()), + "frames": bool(self.field_texts[2].strip()), + "layers": bool(self.field_texts[3].strip()), } ExportOptionsDialog.do_export(self.ui.app, self.filename, options) class PNGSetExporter(ArtExporter): - format_name = 'PNG image set' + format_name = "PNG image set" format_description = """ PNG image set for each frame and/or layer. """ file_extension = FILE_EXTENSION options_dialog_class = PNGSetExportOptionsDialog - + def run_export(self, out_filename, options): - export_frames = options['frames'] - export_layers = options['layers'] + export_frames = options["frames"] + export_layers = options["layers"] art = self.app.ui.active_art # remember user's active frame/layer/viz settings so we # can set em back when done @@ -145,7 +154,9 @@ PNG image set for each frame and/or layer. start_layer_viz = self.app.inactive_layer_visibility self.app.onion_frames_visible = False # if multi-player, only show active layer - self.app.inactive_layer_visibility = LAYER_VIS_NONE if export_layers else LAYER_VIS_FULL + self.app.inactive_layer_visibility = ( + LAYER_VIS_NONE if export_layers else LAYER_VIS_FULL + ) success = True for frame in range(art.frames): # if exporting layers but not frames, only export active frame @@ -154,13 +165,21 @@ PNG image set for each frame and/or layer. art.set_active_frame(frame) for layer in range(art.layers): art.set_active_layer(layer) - full_filename = get_full_filename(out_filename, frame, - art.layer_names[layer], - export_frames, export_layers, - self.app.forbidden_filename_chars) - if not export_still_image(self.app, art, full_filename, - crt=options.get('crt', DEFAULT_CRT), - scale=options.get('scale', DEFAULT_SCALE)): + full_filename = get_full_filename( + out_filename, + frame, + art.layer_names[layer], + export_frames, + export_layers, + self.app.forbidden_filename_chars, + ) + if not export_still_image( + self.app, + art, + full_filename, + crt=options.get("crt", DEFAULT_CRT), + scale=options.get("scale", DEFAULT_SCALE), + ): success = False # put everything back how user left it art.set_active_frame(start_frame) diff --git a/formats/out_txt.py b/formats/out_txt.py index 0954e5c..b5a1ee1 100644 --- a/formats/out_txt.py +++ b/formats/out_txt.py @@ -1,29 +1,31 @@ from art_export import ArtExporter + class TextExporter(ArtExporter): - format_name = 'Plain text' + format_name = "Plain text" format_description = """ ASCII art in ordinary text format. Assumes single frame, single layer document. Current character set will be used; make sure it supports any extended characters you want translated. """ - file_extension = 'txt' + file_extension = "txt" + def run_export(self, out_filename, options): # utf-8 is safest encoding to use here, but non-default on Windows - outfile = open(out_filename, 'w', encoding='utf-8') + outfile = open(out_filename, "w", encoding="utf-8") for y in range(self.art.height): for x in range(self.art.width): char = self.art.get_char_index_at(0, 0, x, y) found_char = False - for k,v in self.art.charset.char_mapping.items(): + for k, v in self.art.charset.char_mapping.items(): if v == char: found_char = True outfile.write(k) break # if char not found, just write a blank space if not found_char: - outfile.write(' ') - outfile.write('\n') + outfile.write(" ") + outfile.write("\n") outfile.close() return True diff --git a/framebuffer.py b/framebuffer.py index 08cb664..c814d8b 100644 --- a/framebuffer.py +++ b/framebuffer.py @@ -3,16 +3,18 @@ from OpenGL import GL class Framebuffer: - start_crt_enabled = False disable_crt = False clear_color = (0, 0, 0, 1) # declared as an option here in case people want to sub their own via CFG - crt_fragment_shader_filename = 'framebuffer_f_crt.glsl' - + crt_fragment_shader_filename = "framebuffer_f_crt.glsl" + def __init__(self, app, width=None, height=None): self.app = app - self.width, self.height = width or self.app.window_width, height or self.app.window_height + self.width, self.height = ( + width or self.app.window_width, + height or self.app.window_height, + ) # bind vao before compiling shaders if self.app.use_vao: self.vao = GL.glGenVertexArrays(1) @@ -20,65 +22,86 @@ class Framebuffer: self.vbo = GL.glGenBuffers(1) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) fb_verts = np.array([-1, -1, 1, -1, -1, 1, 1, 1], dtype=np.float32) - GL.glBufferData(GL.GL_ARRAY_BUFFER, fb_verts.nbytes, fb_verts, - GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, fb_verts.nbytes, fb_verts, GL.GL_STATIC_DRAW + ) # texture, depth buffer, framebuffer self.texture = GL.glGenTextures(1) self.depth_buffer = GL.glGenRenderbuffers(1) self.framebuffer = GL.glGenFramebuffers(1) self.setup_texture_and_buffers() # shaders - self.plain_shader = self.app.sl.new_shader('framebuffer_v.glsl', 'framebuffer_f.glsl') + self.plain_shader = self.app.sl.new_shader( + "framebuffer_v.glsl", "framebuffer_f.glsl" + ) if not self.disable_crt: - self.crt_shader = self.app.sl.new_shader('framebuffer_v.glsl', self.crt_fragment_shader_filename) + self.crt_shader = self.app.sl.new_shader( + "framebuffer_v.glsl", self.crt_fragment_shader_filename + ) self.crt = self.get_crt_enabled() # shader uniforms and attributes - self.plain_tex_uniform = self.plain_shader.get_uniform_location('fbo_texture') - self.plain_attrib = self.plain_shader.get_attrib_location('v_coord') + self.plain_tex_uniform = self.plain_shader.get_uniform_location("fbo_texture") + self.plain_attrib = self.plain_shader.get_attrib_location("v_coord") GL.glEnableVertexAttribArray(self.plain_attrib) - GL.glVertexAttribPointer(self.plain_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glVertexAttribPointer( + self.plain_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None + ) if not self.disable_crt: - self.crt_tex_uniform = self.crt_shader.get_uniform_location('fbo_texture') - self.crt_time_uniform = self.crt_shader.get_uniform_location('elapsed_time') - self.crt_res_uniform = self.crt_shader.get_uniform_location('resolution') + self.crt_tex_uniform = self.crt_shader.get_uniform_location("fbo_texture") + self.crt_time_uniform = self.crt_shader.get_uniform_location("elapsed_time") + self.crt_res_uniform = self.crt_shader.get_uniform_location("resolution") GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) if self.app.use_vao: GL.glBindVertexArray(0) - + def get_crt_enabled(self): return self.disable_crt or self.start_crt_enabled - + def setup_texture_and_buffers(self): GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture) - GL.glTexParameterf(GL.GL_TEXTURE_2D, - GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR) - GL.glTexParameterf(GL.GL_TEXTURE_2D, - GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR) - GL.glTexParameterf(GL.GL_TEXTURE_2D, - GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE) - GL.glTexParameterf(GL.GL_TEXTURE_2D, - GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE) - GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA, - self.width, self.height, 0, - GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, None) + GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR) + GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR) + GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE) + GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE) + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + 0, + GL.GL_RGBA, + self.width, + self.height, + 0, + GL.GL_RGBA, + GL.GL_UNSIGNED_BYTE, + None, + ) GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, self.depth_buffer) - GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT16, - self.width, self.height) + GL.glRenderbufferStorage( + GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT16, self.width, self.height + ) GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, 0) GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self.framebuffer) - GL.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, - GL.GL_TEXTURE_2D, self.texture, 0) - GL.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT, - GL.GL_RENDERBUFFER, self.depth_buffer) + GL.glFramebufferTexture2D( + GL.GL_FRAMEBUFFER, + GL.GL_COLOR_ATTACHMENT0, + GL.GL_TEXTURE_2D, + self.texture, + 0, + ) + GL.glFramebufferRenderbuffer( + GL.GL_FRAMEBUFFER, + GL.GL_DEPTH_ATTACHMENT, + GL.GL_RENDERBUFFER, + self.depth_buffer, + ) GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0) - + def resize(self, new_width, new_height): self.width, self.height = new_width, new_height self.setup_texture_and_buffers() - + def toggle_crt(self): self.crt = not self.crt - + def destroy(self): if self.app.use_vao: GL.glDeleteVertexArrays(1, [self.vao]) @@ -86,7 +109,7 @@ class Framebuffer: GL.glDeleteRenderbuffers(1, [self.depth_buffer]) GL.glDeleteTextures([self.texture]) GL.glDeleteFramebuffers(1, [self.framebuffer]) - + def render(self): if self.crt and not self.disable_crt: GL.glUseProgram(self.crt_shader.program) @@ -104,7 +127,9 @@ class Framebuffer: GL.glBindVertexArray(self.vao) else: GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) - GL.glVertexAttribPointer(self.plain_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None) + GL.glVertexAttribPointer( + self.plain_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None + ) GL.glEnableVertexAttribArray(self.plain_attrib) GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4) if self.app.use_vao: @@ -114,9 +139,13 @@ class Framebuffer: class ExportFramebuffer(Framebuffer): clear_color = (0, 0, 0, 0) - def get_crt_enabled(self): return True + + def get_crt_enabled(self): + return True class ExportFramebufferNoCRT(Framebuffer): clear_color = (0, 0, 0, 0) - def get_crt_enabled(self): return False + + def get_crt_enabled(self): + return False diff --git a/game_hud.py b/game_hud.py index c39f266..be46534 100644 --- a/game_hud.py +++ b/game_hud.py @@ -1,10 +1,9 @@ - from art import Art from renderable import TileRenderable class GameHUDArt(Art): - #recalc_quad_height = False + # recalc_quad_height = False log_creation = False quad_width = 0.1 @@ -13,33 +12,33 @@ class GameHUDRenderable(TileRenderable): def get_projection_matrix(self): # much like UIRenderable, use UI's matrices to render in screen space return self.app.ui.view_matrix + def get_view_matrix(self): return self.app.ui.view_matrix class GameHUD: - "stub HUD, subclass and put your own stuff here" - + def __init__(self, world): self.world = world self.arts, self.renderables = [], [] - + def update(self): for art in self.arts: art.update() for r in self.renderables: r.update() - + def should_render(self): return True - + def render(self): if not self.should_render(): return for r in self.renderables: r.render() - + def destroy(self): for r in self.renderables: r.destroy() diff --git a/game_object.py b/game_object.py index 6e16ea1..5d3a80e 100644 --- a/game_object.py +++ b/game_object.py @@ -1,14 +1,22 @@ -import os, math, random - -from collections import namedtuple +import math +import os +import random import vector - -from art import Art, ArtInstance +from art import ArtInstance +from collision import ( + CST_AABB, + CST_CIRCLE, + CST_NONE, + CST_TILE, + CT_NONE, + CTG_DYNAMIC, + Collideable, + Contact, + point_in_box, +) from renderable import GameObjectRenderable -from renderable_line import OriginIndicatorRenderable, BoundsIndicatorRenderable - -from collision import Contact, Collideable, CST_NONE, CST_CIRCLE, CST_AABB, CST_TILE, CT_NONE, CTG_STATIC, CTG_DYNAMIC, point_in_box +from renderable_line import BoundsIndicatorRenderable, OriginIndicatorRenderable # facings GOF_LEFT = 0 @@ -20,23 +28,18 @@ GOF_FRONT = 2 GOF_BACK = 3 "Object is facing back" -FACINGS = { - GOF_LEFT: 'left', - GOF_RIGHT: 'right', - GOF_FRONT: 'front', - GOF_BACK: 'back' -} +FACINGS = {GOF_LEFT: "left", GOF_RIGHT: "right", GOF_FRONT: "front", GOF_BACK: "back"} "Dict mapping GOF_* facing enum values to strings" FACING_DIRS = { GOF_LEFT: (-1, 0), GOF_RIGHT: (1, 0), GOF_FRONT: (0, -1), - GOF_BACK: (0, 1) + GOF_BACK: (0, 1), } "Dict mapping GOF_* facing enum values to (x,y) orientations" -DEFAULT_STATE = 'stand' +DEFAULT_STATE = "stand" # timer slots TIMER_PRE_UPDATE = 0 @@ -44,7 +47,7 @@ TIMER_UPDATE = 1 TIMER_POST_UPDATE = 2 __pdoc__ = {} -__pdoc__['GameObject.x'] = "Object's location in 3D space." +__pdoc__["GameObject.x"] = "Object's location in 3D space." class GameObject: @@ -57,7 +60,8 @@ class GameObject: See game_util_object module for some generic subclasses for things like a player, spawners, triggers, attachments etc. """ - art_src = 'game_object_default' + + art_src = "game_object_default" """ If specified, this art file will be loaded from disk and used as object's default appearance. If object has states/facings, this is the "base" @@ -84,7 +88,7 @@ class GameObject: art_charset, art_palette = None, None y_sort = False "If True, object will sort according to its Y position a la Zelda LttP" - lifespan = 0. + lifespan = 0.0 "If >0, object will self-destroy after this many seconds" kill_distance_from_origin = 1000 """ @@ -103,11 +107,11 @@ class GameObject: # 2 = each step is half object's size # N = each step is 1/N object's size """ - move_accel_x = move_accel_y = 200. + move_accel_x = move_accel_y = 200.0 "Acceleration per update from player movement" ground_friction = 10.0 air_friction = 25.0 - mass = 1. + mass = 1.0 "Mass: negative number = infinitely dense" bounciness = 0.25 "Bounciness aka restitution, % of velocity reflected on bounce" @@ -117,7 +121,7 @@ class GameObject: log_load = False log_spawn = False visible = True - alpha = 1. + alpha = 1.0 locked = False "If True, location is protected from edit mode drags, can't click to select" show_origin = False @@ -127,15 +131,15 @@ class GameObject: "Collision shape: tile, circle, AABB - see the CST_* enum values" collision_type = CT_NONE "Type of collision (static, dynamic)" - col_layer_name = 'collision' + col_layer_name = "collision" "Collision layer name for CST_TILE objects" draw_col_layer = False "If True, collision layer will draw normally" - col_offset_x, col_offset_y = 0., 0. + col_offset_x, col_offset_y = 0.0, 0.0 "Collision circle/box offset from origin" - col_radius = 1. + col_radius = 1.0 "Collision circle size, if CST_CIRCLE" - col_width, col_height = 1., 1. + col_width, col_height = 1.0, 1.0 "Collision AABB size, if CST_AABB" art_off_pct_x, art_off_pct_y = 0.5, 0.5 """ @@ -144,21 +148,47 @@ class GameObject: """ should_save = True "If True, write this object to state save files" - serialized = ['name', 'x', 'y', 'z', 'art_src', 'visible', 'locked', 'y_sort', - 'art_off_pct_x', 'art_off_pct_y', 'alpha', 'state', 'facing', - 'animating', 'scale_x', 'scale_y'] + serialized = [ + "name", + "x", + "y", + "z", + "art_src", + "visible", + "locked", + "y_sort", + "art_off_pct_x", + "art_off_pct_y", + "alpha", + "state", + "facing", + "animating", + "scale_x", + "scale_y", + ] "List of members to serialize (no weak refs!)" - editable = ['show_collision', 'col_radius', 'col_width', 'col_height', - 'mass', 'bounciness', 'stop_velocity'] + editable = [ + "show_collision", + "col_radius", + "col_width", + "col_height", + "mass", + "bounciness", + "stop_velocity", + ] """ Members that don't need to be serialized, but should be exposed to object edit UI """ - set_methods = {'art_src': 'set_art_src', 'alpha': '_set_alpha', - 'scale_x': '_set_scale_x', 'scale_y': '_set_scale_y', - 'name': '_rename', 'col_radius': '_set_col_radius', - 'col_width': '_set_col_width', - 'col_height': '_set_col_height' + set_methods = { + "art_src": "set_art_src", + "alpha": "_set_alpha", + "scale_x": "_set_scale_x", + "scale_y": "_set_scale_y", + "name": "_rename", + "col_radius": "_set_col_radius", + "col_width": "_set_col_width", + "col_height": "_set_col_height", } "If setting a given member should run some logic, specify the method here" selectable = True @@ -190,13 +220,14 @@ class GameObject: "If True, handle mouse click/wheel events passed in from world / input handler" consume_mouse_events = False "If True, prevent any other mouse click/wheel events from being processed" + def __init__(self, world, obj_data=None): """ Create new GameObject in world, from serialized data if provided. """ - self.x, self.y, self.z = 0., 0., 0. + self.x, self.y, self.z = 0.0, 0.0, 0.0 "Object's location in 3D space." - self.scale_x, self.scale_y, self.scale_z = 1., 1., 1. + self.scale_x, self.scale_y, self.scale_z = 1.0, 1.0, 1.0 "Object's scale in 3D space." self.rooms = {} "Dict of rooms we're in - if empty, object appears in all rooms" @@ -209,9 +240,11 @@ class GameObject: # properties that need non-None defaults should be declared above if obj_data: for v in self.serialized: - if not v in obj_data: + if v not in obj_data: if self.log_load: - self.app.dev_log("Serialized property '%s' not found for %s" % (v, self.name)) + self.app.dev_log( + "Serialized property '%s' not found for %s" % (v, self.name) + ) continue # if value is in data and serialized list but undeclared, do so if not hasattr(self, v): @@ -253,10 +286,14 @@ class GameObject: "Dict of all Arts this object can reference, eg for states" # if art_src not specified, create a new art according to dimensions if self.generate_art: - self.art_src = '%s_art' % self.name - self.art = self.app.new_art(self.art_src, self.art_width, - self.art_height, self.art_charset, - self.art_palette) + self.art_src = "%s_art" % self.name + self.art = self.app.new_art( + self.art_src, + self.art_width, + self.art_height, + self.art_charset, + self.art_palette, + ) # generated art will likely be only entry in this dict, # but make sure it's there (eg generated art for Characters) self.arts[self.art_src] = self.art @@ -278,7 +315,7 @@ class GameObject: self.bounds_renderable = BoundsIndicatorRenderable(self.app, self) "1px LineRenderable showing object's bounding box" for art in self.arts.values(): - if not art in self.world.art_loaded: + if art not in self.world.art_loaded: self.world.art_loaded.append(art) self.orig_collision_type = self.collision_type "Remember last collision type for enable/disable - don't set manually!" @@ -286,7 +323,7 @@ class GameObject: self.world.new_objects[self.name] = self self.attachments = [] if self.attachment_classes: - for atch_name,atch_class_name in self.attachment_classes.items(): + for atch_name, atch_class_name in self.attachment_classes.items(): atch_class = self.world.classes[atch_class_name] attachment = atch_class(self.world) self.attachments.append(attachment) @@ -305,24 +342,27 @@ class GameObject: if self.animating and self.art.frames > 0: self.start_animating() if self.log_spawn: - self.app.log('Spawned %s with Art %s' % (self.name, os.path.basename(self.art.filename))) - + self.app.log( + "Spawned %s with Art %s" + % (self.name, os.path.basename(self.art.filename)) + ) + def get_unique_name(self): "Generate and return a somewhat human-readable unique name for object" name = str(self) - return '%s_%s' % (type(self).__name__, name[name.rfind('x')+1:-1]) - + return "%s_%s" % (type(self).__name__, name[name.rfind("x") + 1 : -1]) + def _rename(self, new_name): # pass thru to world, this method exists for edit set method self.world.rename_object(self, new_name) - + def pre_first_update(self): """ Run before first update; use this for any logic that depends on init/creation being done ie all objects being present. """ pass - + def load_arts(self): "Fill self.arts dict with Art references for eg states and facings." self.art = self.app.load_art(self.art_src, False) @@ -336,24 +376,24 @@ class GameObject: if self.facing_changes_art: # load each facing for each state for facing in FACINGS.values(): - art_name = '%s_%s_%s' % (self.art_src, state, facing) + art_name = "%s_%s_%s" % (self.art_src, state, facing) art = self.app.load_art(art_name, False) if art: self.arts[art_name] = art else: # load each state - art_name = '%s_%s' % (self.art_src, state) + art_name = "%s_%s" % (self.art_src, state) art = self.app.load_art(art_name, False) if art: self.arts[art_name] = art # get reasonable default pose self.art, self.flip_x = self.get_art_for_state() - + def is_point_inside(self, x, y): "Return True if given point is inside our bounds" left, top, right, bottom = self.get_edges() return point_in_box(x, y, left, top, right, bottom) - + def get_edges(self): "Return coords of our bounds (left, top, right, bottom)" left = self.x - (self.renderable.width * self.art_off_pct_x) @@ -361,21 +401,21 @@ class GameObject: top = self.y + (self.renderable.height * self.art_off_pct_y) bottom = self.y - (self.renderable.height * (1 - self.art_off_pct_y)) return left, top, right, bottom - + def distance_to_object(self, other): "Return distance from center of this object to center of given object." return self.distance_to_point(other.x, other.y) - + def distance_to_point(self, point_x, point_y): "Return distance from center of this object to given point." dx = self.x - point_x dy = self.y - point_y - return math.sqrt(dx ** 2 + dy ** 2) - + return math.sqrt(dx**2 + dy**2) + def normal_to_object(self, other): "Return tuple normal pointing in direction of given object." return self.normal_to_point(other.x, other.y) - + def normal_to_point(self, point_x, point_y): "Return tuple normal pointing in direction of given point." dist = self.distance_to_point(point_x, point_y) @@ -384,45 +424,44 @@ class GameObject: return 0, 0 inv_dist = 1 / dist return dx * inv_dist, dy * inv_dist - + def get_render_offset(self): "Return a custom render offset. Override this in subclasses as needed." return 0, 0, 0 - + def is_dynamic(self): "Return True if object is dynamic." return self.collision_type in CTG_DYNAMIC - + def is_entering_state(self, state): "Return True if object is in given state this frame but not last frame." return self.state == state and self.last_state != state - + def is_exiting_state(self, state): "Return True if object is in given state last frame but not this frame." return self.state != state and self.last_state == state - + def play_sound(self, sound_name, loops=0, allow_multiple=False): "Start playing given sound." # use sound_name as filename if it's not in our filenames dict sound_filename = self.sound_filenames.get(sound_name, sound_name) sound_filename = self.world.sounds_dir + sound_filename - self.world.app.al.object_play_sound(self, sound_filename, - loops, allow_multiple) - + self.world.app.al.object_play_sound(self, sound_filename, loops, allow_multiple) + def stop_sound(self, sound_name): "Stop playing given sound." sound_filename = self.sound_filenames.get(sound_name, sound_name) sound_filename = self.world.sounds_dir + sound_filename self.world.app.al.object_stop_sound(self, sound_filename) - + def stop_all_sounds(self): "Stop all sounds playing on object." self.world.app.al.object_stop_all_sounds(self) - + def enable_collision(self): "Enable this object's collision." self.collision_type = self.orig_collision_type - + def disable_collision(self): "Disable this object's collision." if self.collision_type == CT_NONE: @@ -430,27 +469,27 @@ class GameObject: # remember prior collision type self.orig_collision_type = self.collision_type self.collision_type = CT_NONE - + def started_overlapping(self, other): """ Run when object begins overlapping with, but does not collide with, another object. """ pass - + def started_colliding(self, other): "Run when object begins colliding with another object." self.resolve_collision_momentum(other) - + def stopped_colliding(self, other): "Run when object stops colliding with another object." - if not other.name in self.collision.contacts: + if other.name not in self.collision.contacts: # TODO: understand why this spams when player has a MazePickup - #self.world.app.log("%s stopped colliding with %s but wasn't in its contacts!" % (self.name, other.name)) + # self.world.app.log("%s stopped colliding with %s but wasn't in its contacts!" % (self.name, other.name)) return # called from check_finished_contacts self.collision.contacts.pop(other.name) - + def resolve_collision_momentum(self, other): "Resolve velocities between this object and given other object." # don't resolve a pair twice @@ -460,8 +499,10 @@ class GameObject: total_vel = self.vel_x + self.vel_y + other.vel_x + other.vel_y # negative mass = infinite total_mass = max(0, self.mass) + max(0, other.mass) - if other.name not in self.collision.contacts or \ - self.name not in other.collision.contacts: + if ( + other.name not in self.collision.contacts + or self.name not in other.collision.contacts + ): return # redistribute velocity based on mass we're colliding with if self.is_dynamic() and self.mass >= 0: @@ -479,7 +520,7 @@ class GameObject: # mark objects as resolved self.world.cl.collisions_this_frame.append(self) self.world.cl.collisions_this_frame.append(other) - + def check_finished_contacts(self): """ Updates our Collideable's contacts dict for contacts that were @@ -489,7 +530,7 @@ class GameObject: finished = [] # keep separate list of names of objects no longer present destroyed = [] - for obj_name,contact in self.collision.contacts.items(): + for obj_name, contact in self.collision.contacts.items(): if contact.timestamp < self.world.cl.ticks: # object might have been destroyed obj = self.world.objects.get(obj_name, None) @@ -502,11 +543,11 @@ class GameObject: for obj in finished: self.stopped_colliding(obj) obj.stopped_colliding(self) - + def get_contacting_objects(self): "Return list of all objects we're currently contacting." return [self.world.objects[obj] for obj in self.collision.contacts] - + def get_collisions(self): "Return list of all overlapping shapes our shapes should collide with." overlaps = [] @@ -524,19 +565,19 @@ class GameObject: for other in shape.get_overlapping_static_shapes(): overlaps.append(other) return overlaps - + def is_overlapping(self, other): "Return True if we overlap with other object's collision" return other.name in self.collision.contacts - + def are_bounds_overlapping(self, other): "Return True if we overlap with other object's Art's bounds" left, top, right, bottom = self.get_edges() - for x,y in [(left, top), (right, top), (right, bottom), (left, bottom)]: + for x, y in [(left, top), (right, top), (right, bottom), (left, bottom)]: if other.is_point_inside(x, y): return True return False - + def get_tile_at_point(self, point_x, point_y): "Return x,y tile coord for given worldspace point" left, top, right, bottom = self.get_edges() @@ -545,8 +586,10 @@ class GameObject: y = (point_y - top) / self.art.quad_height y = math.ceil(-y) return x, y - - def get_tiles_overlapping_box(self, box_left, box_top, box_right, box_bottom, log=False): + + def get_tiles_overlapping_box( + self, box_left, box_top, box_right, box_bottom, log=False + ): "Returns x,y coords for each tile overlapping given box" if self.collision_shape_type != CST_TILE: return [] @@ -565,7 +608,7 @@ class GameObject: for y in range(top - 1, bottom): tiles.append((x, y)) return tiles - + def overlapped(self, other, overlap): """ Called by CollisionLord when two objects overlap. @@ -573,13 +616,12 @@ class GameObject: """ started = other.name not in self.collision.contacts # create or update contact info: (overlap, timestamp) - self.collision.contacts[other.name] = Contact(overlap, - self.world.cl.ticks) + self.collision.contacts[other.name] = Contact(overlap, self.world.cl.ticks) can_collide = self.can_collide_with(other) if not can_collide and started: self.started_overlapping(other) return can_collide, started - + def get_tile_loc(self, tile_x, tile_y, tile_center=True): "Return top left / center of current Art's tile in world coordinates" left, top, right, bottom = self.get_edges() @@ -591,23 +633,23 @@ class GameObject: x += self.art.quad_width / 2 y -= self.art.quad_height / 2 return x, y - + def get_layer_z(self, layer_name): "Return Z of layer with given name" return self.z + self.art.layers_z[self.art.layer_names.index(layer_name)] - + def get_all_art(self): "Return a list of all Art used by this object" return list(self.arts.keys()) - + def start_animating(self): "Start animation playback." self.renderable.start_animating() - + def stop_animating(self): "Pause animation playback on current frame." self.renderable.stop_animating() - + def set_object_property(self, prop_name, new_value): "Set property by given name to given value." if not hasattr(self, prop_name): @@ -617,30 +659,30 @@ class GameObject: method(new_value) else: setattr(self, prop_name, new_value) - + def get_art_for_state(self, state=None): "Return Art (and 'flip X' bool) that best represents current state" # use current state if none specified state = self.state if state is None else state - art_state_name = '%s_%s' % (self.art_src, self.state) + art_state_name = "%s_%s" % (self.art_src, self.state) # simple case: no facing, just state if not self.facing_changes_art: # return art for current state, use default if not available if art_state_name in self.arts: return self.arts[art_state_name], False else: - default_name = '%s_%s' % (self.art_src, self.state or DEFAULT_STATE) - #assert(default_name in self.arts + default_name = "%s_%s" % (self.art_src, self.state or DEFAULT_STATE) + # assert(default_name in self.arts # don't assert - if base+state name available, use that if default_name in self.arts: return self.arts[default_name], False else: - #self.app.log('%s: Art with name %s not available, using %s' % (self.name, default_name, self.art_src)) + # self.app.log('%s: Art with name %s not available, using %s' % (self.name, default_name, self.art_src)) return self.arts[self.art_src], False # more complex case: art determined by both state and facing facing_suffix = FACINGS[self.facing] # first see if anim exists for this exact state, skip subsequent logic - exact_name = '%s_%s' % (art_state_name, facing_suffix) + exact_name = "%s_%s" % (art_state_name, facing_suffix) if exact_name in self.arts: return self.arts[exact_name], False # see what anims are available and try to choose best for facing @@ -651,18 +693,18 @@ class GameObject: break # if NO anims for current state, fall back to default if not has_state: - default_name = '%s_%s' % (self.art_src, DEFAULT_STATE) + default_name = "%s_%s" % (self.art_src, DEFAULT_STATE) art_state_name = default_name - front_name = '%s_%s' % (art_state_name, FACINGS[GOF_FRONT]) - left_name = '%s_%s' % (art_state_name, FACINGS[GOF_LEFT]) - right_name = '%s_%s' % (art_state_name, FACINGS[GOF_RIGHT]) - back_name = '%s_%s' % (art_state_name, FACINGS[GOF_BACK]) + front_name = "%s_%s" % (art_state_name, FACINGS[GOF_FRONT]) + left_name = "%s_%s" % (art_state_name, FACINGS[GOF_LEFT]) + right_name = "%s_%s" % (art_state_name, FACINGS[GOF_RIGHT]) + back_name = "%s_%s" % (art_state_name, FACINGS[GOF_BACK]) has_front = front_name in self.arts has_left = left_name in self.arts has_right = right_name in self.arts has_sides = has_left or has_right # throw an error if nothing basic is available - #assert(has_front or has_sides) + # assert(has_front or has_sides) if not has_front and not has_sides: return self.arts[self.art_src], False # if left/right opposite available, flip it @@ -689,7 +731,7 @@ class GameObject: return self.arts[front_name], False # fall-through: keep using current art return self.art, False - + def set_art(self, new_art, start_animating=True): "Set object to use new given Art (passed by reference)." if new_art is self.art: @@ -701,7 +743,7 @@ class GameObject: self.collision.create_shapes() if (start_animating or self.animating) and new_art.frames > 1: self.renderable.start_animating() - + def set_art_src(self, new_art_filename): "Set object to use new given Art (passed by filename)" if self.art_src == new_art_filename: @@ -714,56 +756,56 @@ class GameObject: self.arts = {} self.load_arts() self.set_art(new_art) - + def set_loc(self, x, y, z=None): "Set this object's location." self.x, self.y = x, y self.z = z or 0 - + def reset_last_loc(self): 'Reset "last location" values used for updating state and fast_move' self.last_x, self.last_y, self.last_z = self.x, self.y, self.z - + def set_scale(self, x, y, z): "Set this object's scale." self.scale_x, self.scale_y, self.scale_z = x, y, z self.renderable.scale_x = self.scale_x self.renderable.scale_y = self.scale_y self.renderable.reset_size() - + def _set_scale_x(self, new_x): self.set_scale(new_x, self.scale_y, self.scale_z) - + def _set_scale_y(self, new_y): self.set_scale(self.scale_x, new_y, self.scale_z) - + def _set_col_radius(self, new_radius): self.col_radius = new_radius self.collision.shapes[0].radius = new_radius - + def _set_col_width(self, new_width): self.col_width = new_width self.collision.shapes[0].halfwidth = new_width / 2 - + def _set_col_height(self, new_height): self.col_height = new_height self.collision.shapes[0].halfheight = new_height / 2 - + def _set_alpha(self, new_alpha): self.renderable.alpha = self.alpha = new_alpha - + def allow_move(self, dx, dy): "Return True only if this object is allowed to move based on input." return True - + def allow_move_x(self, dx): "Return True if given movement in X axis is allowed." return True - + def allow_move_y(self, dy): "Return True if given movement in Y axis is allowed." return True - + def move(self, dir_x, dir_y): """ Input player/sim-initiated velocity. Given value is multiplied by @@ -780,26 +822,26 @@ class GameObject: self.move_x += dir_x if self.allow_move_y(dir_y): self.move_y += dir_y - + def is_on_ground(self): - ''' + """ Return True if object is "on the ground". Subclasses define custom logic here. - ''' + """ return True - + def get_friction(self): "Return friction that should be applied for object's current context." return self.ground_friction if self.is_on_ground() else self.air_friction - + def is_affected_by_gravity(self): "Return True if object should be affected by gravity." return False - + def get_gravity(self): "Return x,y,z force of gravity for object's current context." return self.world.gravity_x, self.world.gravity_y, self.world.gravity_z - + def get_acceleration(self, vel_x, vel_y, vel_z): """ Return x,y,z acceleration values for object's current context. @@ -814,7 +856,7 @@ class GameObject: force_z += grav_z * self.mass # friction / drag friction = self.get_friction() - speed = math.sqrt(vel_x ** 2 + vel_y ** 2 + vel_z ** 2) + speed = math.sqrt(vel_x**2 + vel_y**2 + vel_z**2) force_x -= friction * self.mass * vel_x force_y -= friction * self.mass * vel_y force_z -= friction * self.mass * vel_z @@ -825,13 +867,15 @@ class GameObject: # zero out acceleration beneath a threshold # TODO: determine if this should be made tunable return vector.cut_xyz(accel_x, accel_y, accel_z, 0.01) - + def apply_move(self): """ Apply current acceleration / velocity to position using Verlet integration with half-step velocity estimation. """ - accel_x, accel_y, accel_z = self.get_acceleration(self.vel_x, self.vel_y, self.vel_z) + accel_x, accel_y, accel_z = self.get_acceleration( + self.vel_x, self.vel_y, self.vel_z + ) timestep = self.world.app.timestep / 1000 hsvel_x = self.vel_x + 0.5 * timestep * accel_x hsvel_y = self.vel_y + 0.5 * timestep * accel_y @@ -843,31 +887,37 @@ class GameObject: self.vel_x = hsvel_x + 0.5 * timestep * accel_x self.vel_y = hsvel_y + 0.5 * timestep * accel_y self.vel_z = hsvel_z + 0.5 * timestep * accel_z - self.vel_x, self.vel_y, self.vel_z = vector.cut_xyz(self.vel_x, self.vel_y, self.vel_z, self.stop_velocity) - + self.vel_x, self.vel_y, self.vel_z = vector.cut_xyz( + self.vel_x, self.vel_y, self.vel_z, self.stop_velocity + ) + def moved_this_frame(self): "Return True if object changed locations this frame." - delta = math.sqrt(abs(self.last_x - self.x) ** 2 + abs(self.last_y - self.y) ** 2 + abs(self.last_z - self.z) ** 2) + delta = math.sqrt( + abs(self.last_x - self.x) ** 2 + + abs(self.last_y - self.y) ** 2 + + abs(self.last_z - self.z) ** 2 + ) return delta > self.stop_velocity - + def warped_recently(self): "Return True if object warped during last update." return self.world.updates - self.last_warp_update <= 0 - + def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): """ Handle "key pressed" event, with keyboard mods passed in. GO subclasses can do stuff here if their handle_key_events=True """ pass - + def handle_key_up(self, key, shift_pressed, alt_pressed, ctrl_pressed): """ Handle "key released" event, with keyboard mods passed in. GO subclasses can do stuff here if their handle_key_events=True """ pass - + def clicked(self, button, mouse_x, mouse_y): """ Handle mouse button down event, with button # and @@ -875,7 +925,7 @@ class GameObject: GO subclasses can do stuff here if their handle_mouse_events=True """ pass - + def unclicked(self, button, mouse_x, mouse_y): """ Handle mouse button up event, with button # and @@ -883,30 +933,37 @@ class GameObject: GO subclasses can do stuff here if their handle_mouse_events=True """ pass - + def hovered(self, mouse_x, mouse_y): """ Handle mouse hover (fires when object -starts- being hovered). GO subclasses can do stuff here if their handle_mouse_events=True """ pass - + def unhovered(self, mouse_x, mouse_y): """ Handle mouse unhover. GO subclasses can do stuff here if their handle_mouse_events=True """ pass - + def mouse_wheeled(self, wheel_y): """ Handle mouse wheel movement. GO subclasses can do stuff here if their handle_mouse_events=True """ pass - - def set_timer_function(self, timer_name, timer_function, delay_min, - delay_max=0, repeats=-1, slot=TIMER_PRE_UPDATE): + + def set_timer_function( + self, + timer_name, + timer_function, + delay_min, + delay_max=0, + repeats=-1, + slot=TIMER_PRE_UPDATE, + ): """ Run given function in X seconds or every X seconds Y times. If max is given, next execution will be between min and max time. @@ -914,31 +971,44 @@ class GameObject: "Slot" determines whether function will run in pre_update, update, or post_update. """ - timer = GameObjectTimerFunction(self, timer_name, timer_function, - delay_min, delay_max, repeats, slot) + timer = GameObjectTimerFunction( + self, timer_name, timer_function, delay_min, delay_max, repeats, slot + ) # add to slot-appropriate dict - d = [self.timer_functions_pre_update, self.timer_functions_update, - self.timer_functions_post_update][slot] + d = [ + self.timer_functions_pre_update, + self.timer_functions_update, + self.timer_functions_post_update, + ][slot] d[timer_name] = timer - + def stop_timer_function(self, timer_name): "Stop currently running timer function with given name." - timer = self.timer_functions_pre_update.get(timer_name, None) or \ - self.timer_functions_update.get(timer_name, None) or \ - self.timer_functions_post_update.get(timer_name, None) + timer = ( + self.timer_functions_pre_update.get(timer_name, None) + or self.timer_functions_update.get(timer_name, None) + or self.timer_functions_post_update.get(timer_name, None) + ) if not timer: - self.app.log('Timer named %s not found on object %s' % (timer_name, - self.name)) - d = [self.timer_functions_pre_update, self.timer_functions_update, - self.timer_functions_post_update][timer.slot] + self.app.log( + "Timer named %s not found on object %s" % (timer_name, self.name) + ) + d = [ + self.timer_functions_pre_update, + self.timer_functions_update, + self.timer_functions_post_update, + ][timer.slot] d.pop(timer_name) - + def update_state(self): "Update object state based on current context, eg movement." - if self.state_changes_art and self.stand_if_not_moving and \ - not self.moved_this_frame(): + if ( + self.state_changes_art + and self.stand_if_not_moving + and not self.moved_this_frame() + ): self.state = DEFAULT_STATE - + def update_facing(self): "Update object facing based on current context, eg movement." dx, dy = self.x - self.last_x, self.y - self.last_y @@ -949,15 +1019,15 @@ class GameObject: self.facing = GOF_BACK if dy >= 0 else GOF_FRONT else: self.facing = GOF_RIGHT if dx >= 0 else GOF_LEFT - + def update_state_sounds(self): "Stop and play looping sounds appropriate to current/recent states." - for state,sound in self.looping_state_sounds.items(): + for state, sound in self.looping_state_sounds.items(): if self.is_entering_state(state): self.play_sound(sound, loops=-1) elif self.is_exiting_state(state): self.stop_sound(sound) - + def frame_begin(self): "Run at start of game loop iteration, before input/update/render." self.move_x, self.move_y = 0, 0 @@ -966,7 +1036,7 @@ class GameObject: if self.last_state is None: self.update_state_sounds() self.last_state = self.state - + def frame_update(self): "Run once per frame, after input + simulation update and before render." if not self.art.updated_this_tick: @@ -976,15 +1046,15 @@ class GameObject: new_art, flip_x = self.get_art_for_state() self.set_art(new_art) self.flip_x = flip_x - + def pre_update(self): "Run before any objects have updated this simulation tick." pass - + def post_update(self): "Run after all objects have updated this simulation tick." pass - + def fast_move(self): """ Subdivide object's move this frame into steps to avoid tunneling. @@ -992,7 +1062,7 @@ class GameObject: """ final_x, final_y = self.x, self.y dx, dy = self.x - self.last_x, self.y - self.last_y - total_move_dist = math.sqrt(dx ** 2 + dy ** 2) + total_move_dist = math.sqrt(dx**2 + dy**2) if total_move_dist == 0: return # get movement normal @@ -1003,7 +1073,7 @@ class GameObject: elif self.collision_shape_type == CST_AABB: # get size in axis object is moving in step_x, step_y = self.col_width * dir_x, self.col_height * dir_y - step_dist = math.sqrt(step_x ** 2 + step_y ** 2) + step_dist = math.sqrt(step_x**2 + step_y**2) step_dist /= self.fast_move_steps # if object isn't moving fast enough, don't step if total_move_dist <= step_dist: @@ -1020,11 +1090,11 @@ class GameObject: return # ran through all steps without a hit, set back to final position self.x, self.y = final_x, final_y - + def get_time_since_last_update(self): "Return time (in milliseconds) since end of this object's last update." return self.world.get_elapsed_time() - self.last_update_end - + def update(self): """ Apply movement/physics, update state and facing, keep our Collideable's @@ -1034,7 +1104,7 @@ class GameObject: if 0 < self.destroy_time <= self.world.get_elapsed_time(): self.destroy() # don't apply physics to selected objects being dragged - if self.physics_move and not self.name in self.world.drag_objects: + if self.physics_move and self.name not in self.world.drag_objects: self.apply_move() if self.fast_move_steps > 0: self.fast_move() @@ -1044,11 +1114,16 @@ class GameObject: self.update_facing() # update collision shape before CollisionLord resolves any collisions self.collision.update() - if abs(self.x) > self.kill_distance_from_origin or \ - abs(self.y) > self.kill_distance_from_origin: - self.app.log('%s reached %s from origin, destroying.' % (self.name, self.kill_distance_from_origin)) + if ( + abs(self.x) > self.kill_distance_from_origin + or abs(self.y) > self.kill_distance_from_origin + ): + self.app.log( + "%s reached %s from origin, destroying." + % (self.name, self.kill_distance_from_origin) + ) self.destroy() - + def update_renderables(self): """ Keep our Renderable's location locked to us, and update any debug @@ -1057,45 +1132,50 @@ class GameObject: # even if debug viz are off, update once on init to set correct state if self.show_origin or self in self.world.selected_objects: self.origin_renderable.update() - if self.show_bounds or self in self.world.selected_objects or \ - (self is self.world.hovered_focus_object and self.selectable): + if ( + self.show_bounds + or self in self.world.selected_objects + or (self is self.world.hovered_focus_object and self.selectable) + ): self.bounds_renderable.update() if self.show_collision and self.is_dynamic(): self.collision.update_renderables() if self.visible: self.renderable.update() - + def get_debug_text(self): "Subclass logic can return a string to display in debug line." return None - + def should_collide(self): "Return True if this object should collide in current context." return self.collision_type != CT_NONE and self.is_in_current_room() - + def can_collide_with(self, other): "Return True if this object is allowed to collide with given object." for ncc_name in self.noncolliding_classes: if isinstance(other, self.world.classes[ncc_name]): return False return True - + def is_in_room(self, room): "Return True if this object is in the given (by reference) Room." return len(self.rooms) == 0 or room.name in self.rooms - + def is_in_current_room(self): "Return True if this object is in the world's currently active Room." - return len(self.rooms) == 0 or (self.world.current_room and self.world.current_room.name in self.rooms) - + return len(self.rooms) == 0 or ( + self.world.current_room and self.world.current_room.name in self.rooms + ) + def room_entered(self, room, old_room): "Run when a room we're in is entered." pass - + def room_exited(self, room, new_room): "Run when a room we're in is exited." pass - + def render_debug(self): "Render debug lines, eg origin/bounds/collision." # only show debug stuff if in edit mode @@ -1103,16 +1183,19 @@ class GameObject: return if self.show_origin or self in self.world.selected_objects: self.origin_renderable.render() - if self.show_bounds or self in self.world.selected_objects or \ - (self.selectable and self is self.world.hovered_focus_object): + if ( + self.show_bounds + or self in self.world.selected_objects + or (self.selectable and self is self.world.hovered_focus_object) + ): self.bounds_renderable.render() if self.show_collision and self.collision_type != CT_NONE: self.collision.render() - + def render(self, layer, z_override=None): - #print('GameObject %s layer %s has Z %s' % (self.art.filename, layer, self.art.layers_z[layer])) + # print('GameObject %s layer %s has Z %s' % (self.art.filename, layer, self.art.layers_z[layer])) self.renderable.render(layer, z_override) - + def get_dict(self): """ Return a dict serializing this object's state that @@ -1120,21 +1203,21 @@ class GameObject: this object's "serialized" list are stored. Direct object references are not safe to serialize, use only primitive types like strings. """ - d = { 'class_name': type(self).__name__ } + d = {"class_name": type(self).__name__} # serialize whatever other vars are declared in self.serialized for prop_name in self.serialized: if hasattr(self, prop_name): d[prop_name] = getattr(self, prop_name) return d - + def reset_in_place(self): "Run GameWorld.reset_object_in_place on this object." self.world.reset_object_in_place(self) - + def set_destroy_timer(self, destroy_in_seconds): "Set object to destroy itself given number of seconds from now." self.destroy_time = self.world.get_elapsed_time() + destroy_in_seconds * 1000 - + def destroy(self): self.stop_all_sounds() # remove rooms' references to us @@ -1145,8 +1228,10 @@ class GameObject: if self in self.world.selected_objects: self.world.selected_objects.remove(self) if self.spawner: - if hasattr(self.spawner, 'spawned_objects') and \ - self in self.spawner.spawned_objects: + if ( + hasattr(self.spawner, "spawned_objects") + and self in self.spawner.spawned_objects + ): self.spawner.spawned_objects.remove(self) self.origin_renderable.destroy() self.bounds_renderable.destroy() @@ -1162,6 +1247,7 @@ class GameObjectTimerFunction: Object that manages a function's execution schedule for a GameObject. Use GameObject.set_timer_function to create these. """ + def __init__(self, go, name, function, delay_min, delay_max, repeats, slot): self.go = go "GameObject using this timer" @@ -1180,7 +1266,7 @@ class GameObjectTimerFunction: self.next_update = self.go.world.get_elapsed_time() self.runs = 0 self._set_next_time() - + def _set_next_time(self): "Compute and set this timer's next update time" # if no max delay, just use min, else rand(min, max) @@ -1190,7 +1276,7 @@ class GameObjectTimerFunction: delay = random.random() * (self.delay_max - self.delay_min) delay += self.delay_min self.next_update += int(delay * 1000) - + def update(self): "Check timer, running function as needed" if self.go.world.get_elapsed_time() < self.next_update: @@ -1202,7 +1288,7 @@ class GameObjectTimerFunction: self.go.stop_timer_function(self.name) else: self._set_next_time() - + def _execute(self): # pass our object into our function self.function() diff --git a/game_room.py b/game_room.py index 488e7d7..cfb45b1 100644 --- a/game_room.py +++ b/game_room.py @@ -1,27 +1,35 @@ - from game_object import GameObject + class GameRoom: """ A collection of GameObjects within a GameWorld. Can be used to limit scope of object updates, collisions, etc. """ - camera_marker_name = '' + + camera_marker_name = "" "If set, camera will move to marker with this name when room entered" camera_follow_player = False "If True, camera will follow player while in this room" - left_edge_warp_dest_name, right_edge_warp_dest_name = '', '' + left_edge_warp_dest_name, right_edge_warp_dest_name = "", "" "If set, warp to room OR marker with this name when edge crossed" - top_edge_warp_dest_name, bottom_edge_warp_dest_name = '', '' - warp_edge_bounds_obj_name = '' - "Object whose art's bounds should be used as our \"edges\" for above" - serialized = ['name', 'camera_marker_name', 'left_edge_warp_dest_name', - 'right_edge_warp_dest_name', 'top_edge_warp_dest_name', - 'bottom_edge_warp_dest_name', 'warp_edge_bounds_obj_name', - 'camera_follow_player'] + top_edge_warp_dest_name, bottom_edge_warp_dest_name = "", "" + warp_edge_bounds_obj_name = "" + 'Object whose art\'s bounds should be used as our "edges" for above' + serialized = [ + "name", + "camera_marker_name", + "left_edge_warp_dest_name", + "right_edge_warp_dest_name", + "top_edge_warp_dest_name", + "bottom_edge_warp_dest_name", + "warp_edge_bounds_obj_name", + "camera_follow_player", + ] "List of string names of members to serialize for this Room class." log_changes = False "Log changes to and from this room" + def __init__(self, world, name, room_data=None): self.world = world self.name = name @@ -34,8 +42,10 @@ class GameRoom: # TODO: this is copy-pasted from GameObject, find a way to unify # TODO: GameWorld.set_data_for that takes instance, serialized list, data dict for v in self.serialized: - if not v in room_data: - self.world.app.dev_log("Serialized property '%s' not found for room %s" % (v, self.name)) + if v not in room_data: + self.world.app.dev_log( + "Serialized property '%s' not found for room %s" % (v, self.name) + ) continue if not hasattr(self, v): setattr(self, v, None) @@ -47,18 +57,19 @@ class GameRoom: else: setattr(self, v, room_data[v]) # find objects by name and add them - for obj_name in room_data.get('objects', []): + for obj_name in room_data.get("objects", []): self.add_object_by_name(obj_name) - + def pre_first_update(self): self.reset_edge_warps() - + def reset_edge_warps(self): self.edge_obj = self.world.objects.get(self.warp_edge_bounds_obj_name, None) # no warping if we don't know our bounds if not self.edge_obj: return - edge_dest_name_suffix = '_name' + edge_dest_name_suffix = "_name" + def set_edge_dest(dest_property): # property name to destination name dest_name = getattr(self, dest_property) @@ -66,26 +77,31 @@ class GameRoom: dest_room = self.world.rooms.get(dest_name, None) dest_obj = self.world.objects.get(dest_name, None) # derive member name from serialized property name - member_name = dest_property.replace(edge_dest_name_suffix, '') + member_name = dest_property.replace(edge_dest_name_suffix, "") setattr(self, member_name, dest_room or dest_obj or None) - for pname in ['left_edge_warp_dest_name', 'right_edge_warp_dest_name', - 'top_edge_warp_dest_name', 'bottom_edge_warp_dest_name']: + + for pname in [ + "left_edge_warp_dest_name", + "right_edge_warp_dest_name", + "top_edge_warp_dest_name", + "bottom_edge_warp_dest_name", + ]: set_edge_dest(pname) - + def set_camera_marker_name(self, marker_name): - if not marker_name in self.world.objects: + if marker_name not in self.world.objects: self.world.app.log("Couldn't find camera marker with name %s" % marker_name) return self.camera_marker_name = marker_name if self is self.world.current_room: self.use_camera_marker() - + def use_camera_marker(self): - if not self.camera_marker_name in self.world.objects: + if self.camera_marker_name not in self.world.objects: return cam_mark = self.world.objects[self.camera_marker_name] self.world.camera.set_loc_from_obj(cam_mark) - + def entered(self, old_room): "Run when the player enters this room." if self.log_changes: @@ -100,7 +116,7 @@ class GameRoom: # tell objects in this room player has entered so eg spawners can fire for obj in self.objects.values(): obj.room_entered(self, old_room) - + def exited(self, new_room): "Run when the player exits this room." if self.log_changes: @@ -108,7 +124,7 @@ class GameRoom: # tell objects in this room player has exited for obj in self.objects.values(): obj.room_exited(self, new_room) - + def add_object_by_name(self, obj_name): "Add object with given name to this room." obj = self.world.objects.get(obj_name, None) @@ -116,12 +132,12 @@ class GameRoom: self.world.app.log("Couldn't find object named %s" % obj_name) return self.add_object(obj) - + def add_object(self, obj): "Add object (by reference) to this room." self.objects[obj.name] = obj obj.rooms[self.name] = self - + def remove_object_by_name(self, obj_name): "Remove object with given name from this room." obj = self.world.objects.get(obj_name, None) @@ -129,33 +145,42 @@ class GameRoom: self.world.app.log("Couldn't find object named %s" % obj_name) return self.remove_object(obj) - + def remove_object(self, obj): "Remove object (by reference) from this room." if obj.name in self.objects: self.objects.pop(obj.name) else: - self.world.app.log("GameRoom %s doesn't contain GameObject %s" % (self.name, obj.name)) + self.world.app.log( + "GameRoom %s doesn't contain GameObject %s" % (self.name, obj.name) + ) if self.name in obj.rooms: obj.rooms.pop(self.name) else: - self.world.app.log("GameObject %s not found in GameRoom %s" % (obj.name, self.name)) - + self.world.app.log( + "GameObject %s not found in GameRoom %s" % (obj.name, self.name) + ) + def get_dict(self): "Return a dict that GameWorld.save_to_file can dump to JSON" object_names = list(self.objects.keys()) - d = {'class_name': type(self).__name__, 'objects': object_names} + d = {"class_name": type(self).__name__, "objects": object_names} # serialize whatever other vars are declared in self.serialized for prop_name in self.serialized: if hasattr(self, prop_name): d[prop_name] = getattr(self, prop_name) return d - + def _check_edge_warp(self, game_object): # bail if no bounds or edge warp destinations set if not self.edge_obj: return - if not self.left_edge_warp_dest and not self.right_edge_warp_dest and not self.top_edge_warp_dest and not self.bottom_edge_warp_dest: + if ( + not self.left_edge_warp_dest + and not self.right_edge_warp_dest + and not self.top_edge_warp_dest + and not self.bottom_edge_warp_dest + ): return if game_object.warped_recently(): return @@ -181,11 +206,11 @@ class GameRoom: # TODO: change room or not? use_marker_room flag a la WarpTrigger? game_object.set_loc(warp_dest.x, warp_dest.y) game_object.last_warp_update = self.world.updates - + def update(self): if self is self.world.current_room: self._check_edge_warp(self.world.player) - + def destroy(self): if self.name in self.world.rooms: self.world.rooms.pop(self.name) diff --git a/game_util_objects.py b/game_util_objects.py index 5c3d3fb..f71c5d8 100644 --- a/game_util_objects.py +++ b/game_util_objects.py @@ -1,31 +1,41 @@ +import os.path +import random -import os.path, random +from collision import ( + CST_AABB, + CST_CIRCLE, + CST_TILE, + CT_GENERIC_DYNAMIC, + CT_GENERIC_STATIC, + CT_NONE, + CT_PLAYER, +) +from game_object import FACING_DIRS, GameObject -from game_object import GameObject, FACING_DIRS -from collision import CST_NONE, CST_CIRCLE, CST_AABB, CST_TILE, CT_NONE, CT_GENERIC_STATIC, CT_GENERIC_DYNAMIC, CT_PLAYER, CTG_STATIC, CTG_DYNAMIC class GameObjectAttachment(GameObject): "GameObject that doesn't think about anything, just renders" + collision_type = CT_NONE should_save = False selectable = False exclude_from_class_list = True physics_move = False - offset_x, offset_y, offset_z = 0., 0., 0. + offset_x, offset_y, offset_z = 0.0, 0.0, 0.0 "Offset from parent object's origin" fixed_z = False "If True, Z will not be locked to GO we're attached to" - editable = GameObject.editable + ['offset_x', 'offset_y', 'offset_z'] - + editable = GameObject.editable + ["offset_x", "offset_y", "offset_z"] + def attach_to(self, game_object): "Attach this object to given object." self.parent = game_object - + def update(self): # very minimal update! if not self.art.updated_this_tick: self.art.update() - + def post_update(self): # after parent has moved, snap to its location self.x = self.parent.x + self.offset_x @@ -36,93 +46,112 @@ class GameObjectAttachment(GameObject): class BlobShadow(GameObjectAttachment): "Generic blob shadow attachment class" - art_src = 'blob_shadow' + + art_src = "blob_shadow" alpha = 0.5 + class StaticTileBG(GameObject): "Generic static world object with tile-based collision" + collision_shape_type = CST_TILE collision_type = CT_GENERIC_STATIC physics_move = False + class StaticTileObject(GameObject): collision_shape_type = CST_TILE collision_type = CT_GENERIC_STATIC physics_move = False y_sort = True + class StaticBoxObject(GameObject): "Generic static world object with AABB-based (rectangle) collision" + collision_shape_type = CST_AABB collision_type = CT_GENERIC_STATIC physics_move = False + class DynamicBoxObject(GameObject): collision_shape_type = CST_AABB collision_type = CT_GENERIC_DYNAMIC y_sort = True + class Pickup(GameObject): collision_shape_type = CST_CIRCLE collision_type = CT_GENERIC_DYNAMIC y_sort = True - attachment_classes = { 'shadow': 'BlobShadow' } + attachment_classes = {"shadow": "BlobShadow"} + class Projectile(GameObject): "Generic projectile class" + fast_move_steps = 1 collision_type = CT_GENERIC_DYNAMIC collision_shape_type = CST_CIRCLE - move_accel_x = move_accel_y = 400. - noncolliding_classes = ['Projectile'] - lifespan = 10. + move_accel_x = move_accel_y = 400.0 + noncolliding_classes = ["Projectile"] + lifespan = 10.0 "Projectiles should be transient, limited max life" should_save = False - + def __init__(self, world, obj_data=None): GameObject.__init__(self, world, obj_data) self.fire_dir_x, self.fire_dir_y = 0, 0 - + def fire(self, firer, dir_x=0, dir_y=1): self.set_loc(firer.x, firer.y, firer.z) self.reset_last_loc() self.fire_dir_x, self.fire_dir_y = dir_x, dir_y - + def update(self): if (self.fire_dir_x, self.fire_dir_y) != (0, 0): self.move(self.fire_dir_x, self.fire_dir_y) GameObject.update(self) + class Character(GameObject): "Generic character class" + state_changes_art = True stand_if_not_moving = True - move_state = 'walk' + move_state = "walk" "Move state name - added to valid_states in init so subclasses recognized" collision_shape_type = CST_CIRCLE collision_type = CT_GENERIC_DYNAMIC - + def __init__(self, world, obj_data=None): - if not self.move_state in self.valid_states: + if self.move_state not in self.valid_states: self.valid_states.append(self.move_state) GameObject.__init__(self, world, obj_data) # assume that character should start idling, if its art animates if self.art.frames > 0: self.start_animating() - + def update_state(self): GameObject.update_state(self) if self.state_changes_art and abs(self.vel_x) > 0.1 or abs(self.vel_y) > 0.1: self.state = self.move_state + class Player(Character): "Generic player class" + log_move = False collision_type = CT_PLAYER - editable = Character.editable + ['move_accel_x', 'move_accel_y', - 'ground_friction', 'air_friction', - 'bounciness', 'stop_velocity'] - + editable = Character.editable + [ + "move_accel_x", + "move_accel_y", + "ground_friction", + "air_friction", + "bounciness", + "stop_velocity", + ] + def pre_first_update(self): if self.world.player is None: self.world.player = self @@ -130,40 +159,56 @@ class Player(Character): self.world.camera.focus_object = self else: self.world.camera.focus_object = None - + def button_pressed(self, button_index): pass - + def button_unpressed(self, button_index): pass class TopDownPlayer(Player): - y_sort = True - attachment_classes = { 'shadow': 'BlobShadow' } + attachment_classes = {"shadow": "BlobShadow"} facing_changes_art = True - + def get_facing_dir(self): return FACING_DIRS[self.facing] class WorldPropertiesObject(GameObject): "Special magic singleton object that stores and sets GameWorld properties" - art_src = 'world_properties_object' + + art_src = "world_properties_object" visible = deleteable = selectable = False locked = True physics_move = False exclude_from_object_list = True exclude_from_class_list = True - world_props = ['game_title', 'gravity_x', 'gravity_y', 'gravity_z', - 'hud_class_name', 'globals_object_class_name', - 'camera_x', 'camera_y', 'camera_z', - 'bg_color_r', 'bg_color_g', 'bg_color_b', 'bg_color_a', - 'player_camera_lock', 'object_grid_snap', 'draw_hud', - 'collision_enabled', 'show_collision_all', 'show_bounds_all', - 'show_origin_all', 'show_all_rooms', - 'room_camera_changes_enabled', 'draw_debug_objects' + world_props = [ + "game_title", + "gravity_x", + "gravity_y", + "gravity_z", + "hud_class_name", + "globals_object_class_name", + "camera_x", + "camera_y", + "camera_z", + "bg_color_r", + "bg_color_g", + "bg_color_b", + "bg_color_a", + "player_camera_lock", + "object_grid_snap", + "draw_hud", + "collision_enabled", + "show_collision_all", + "show_bounds_all", + "show_origin_all", + "show_all_rooms", + "room_camera_changes_enabled", + "draw_debug_objects", ] """ Properties we serialize on behalf of GameWorld @@ -172,6 +217,7 @@ class WorldPropertiesObject(GameObject): serialized = world_props editable = [] "All visible properties are serialized, not editable" + def __init__(self, world, obj_data=None): GameObject.__init__(self, world, obj_data) world_class = type(world) @@ -188,31 +234,36 @@ class WorldPropertiesObject(GameObject): # set explicitly as float, for camera & bg color setattr(self, v, 0.0) # special handling of bg color (a list) - self.world.bg_color = [self.bg_color_r, self.bg_color_g, self.bg_color_b, self.bg_color_a] + self.world.bg_color = [ + self.bg_color_r, + self.bg_color_g, + self.bg_color_b, + self.bg_color_a, + ] self.world.camera.set_loc(self.camera_x, self.camera_y, self.camera_z) # TODO: figure out why collision_enabled seems to default False! - + def set_object_property(self, prop_name, new_value): setattr(self, prop_name, new_value) # special handling for some values, eg bg color and camera - if prop_name.startswith('bg_color_'): - component = {'r': 0, 'g': 1, 'b': 2, 'a': 3}[prop_name[-1]] + if prop_name.startswith("bg_color_"): + component = {"r": 0, "g": 1, "b": 2, "a": 3}[prop_name[-1]] self.world.bg_color[component] = float(new_value) - elif prop_name.startswith('camera_') and len(prop_name) == len('camera_x'): + elif prop_name.startswith("camera_") and len(prop_name) == len("camera_x"): setattr(self.world.camera, prop_name[-1], new_value) # some properties have unique set methods in GW - elif prop_name == 'show_collision_all': + elif prop_name == "show_collision_all": self.world.toggle_all_collision_viz() - elif prop_name == 'show_bounds_all': + elif prop_name == "show_bounds_all": self.world.toggle_all_bounds_viz() - elif prop_name == 'show_origin_all': + elif prop_name == "show_origin_all": self.world.toggle_all_origin_viz() - elif prop_name == 'player_camera_lock': + elif prop_name == "player_camera_lock": self.world.toggle_player_camera_lock() # normal properties you can just set: set em elif hasattr(self.world, prop_name): setattr(self.world, prop_name, new_value) - + def update_from_world(self): self.camera_x = self.world.camera.x self.camera_y = self.world.camera.y @@ -225,6 +276,7 @@ class WorldGlobalsObject(GameObject): Subclass can be specified in WorldPropertiesObject. NOTE: this object is spawned from scratch every load, it's never serialized! """ + should_save = False visible = deleteable = selectable = False locked = True @@ -237,8 +289,9 @@ class WorldGlobalsObject(GameObject): class LocationMarker(GameObject): "Very simple GameObject that marks an XYZ location for eg camera points" - art_src = 'loc_marker' - serialized = ['name', 'x', 'y', 'z', 'visible', 'locked'] + + art_src = "loc_marker" + serialized = ["name", "x", "y", "z", "visible", "locked"] editable = [] alpha = 0.5 physics_move = False @@ -250,21 +303,24 @@ class StaticTileTrigger(GameObject): Generic static trigger with tile-based collision. Overlaps but doesn't collide. """ + is_debug = True collision_shape_type = CST_TILE collision_type = CT_GENERIC_STATIC - noncolliding_classes = ['GameObject'] + noncolliding_classes = ["GameObject"] physics_move = False - serialized = ['name', 'x', 'y', 'z', 'art_src', 'visible', 'locked'] - + serialized = ["name", "x", "y", "z", "art_src", "visible", "locked"] + def started_overlapping(self, other): - #self.app.log('Trigger overlapped with %s' % other.name) + # self.app.log('Trigger overlapped with %s' % other.name) pass + class WarpTrigger(StaticTileTrigger): "Trigger that warps object to a room/marker when they touch it." + is_debug = True - art_src = 'trigger_default' + art_src = "trigger_default" alpha = 0.5 destination_marker_name = None "If set, warp to this location marker" @@ -272,16 +328,21 @@ class WarpTrigger(StaticTileTrigger): "If set, make this room the world's current" use_marker_room = True "If True, change to destination marker's room" - warp_class_names = ['Player'] + warp_class_names = ["Player"] "List of class names to warp on contact with us." - serialized = StaticTileTrigger.serialized + ['destination_room_name', - 'destination_marker_name', - 'use_marker_room'] - + serialized = StaticTileTrigger.serialized + [ + "destination_room_name", + "destination_marker_name", + "use_marker_room", + ] + def __init__(self, world, obj_data=None): StaticTileTrigger.__init__(self, world, obj_data) - self.warp_classes = [self.world.get_class_by_name(class_name) for class_name in self.warp_class_names] - + self.warp_classes = [ + self.world.get_class_by_name(class_name) + for class_name in self.warp_class_names + ] + def started_overlapping(self, other): if other.warped_recently(): return @@ -307,25 +368,34 @@ class WarpTrigger(StaticTileTrigger): elif self.destination_marker_name: marker = self.world.objects.get(self.destination_marker_name, None) if not marker: - self.app.log('Warp destination object %s not found' % self.destination_marker_name) + self.app.log( + "Warp destination object %s not found" + % self.destination_marker_name + ) return other.set_loc(marker.x, marker.y, marker.z) # warp to marker's room if specified, pick a random one if multiple if self.use_marker_room and len(marker.rooms) == 1: room = random.choice(list(marker.rooms.values())) # warn if both room and marker are set but they conflict - if self.destination_room_name and \ - room.name != self.destination_room_name: - self.app.log("Marker %s's room differs from destination room %s" % (marker.name, self.destination_room_name)) + if ( + self.destination_room_name + and room.name != self.destination_room_name + ): + self.app.log( + "Marker %s's room differs from destination room %s" + % (marker.name, self.destination_room_name) + ) self.world.change_room(room.name) other.last_warp_update = self.world.updates class ObjectSpawner(LocationMarker): "Simple object that spawns an object when triggered" + is_debug = True spawn_class_name = None - spawn_obj_name = '' + spawn_obj_name = "" spawn_random_in_bounds = False "If True, spawn somewhere in this object's bounds, else spawn at location" spawn_obj_data = {} @@ -336,20 +406,23 @@ class ObjectSpawner(LocationMarker): "Set False for any subclass that triggers in some other way" destroy_on_room_exit = True "if True, spawned object will be destroyed when player leaves its room" - serialized = LocationMarker.serialized + ['spawn_class_name', 'spawn_obj_name', - 'times_to_fire', 'destroy_on_room_exit' + serialized = LocationMarker.serialized + [ + "spawn_class_name", + "spawn_obj_name", + "times_to_fire", + "destroy_on_room_exit", ] - + def __init__(self, world, obj_data=None): LocationMarker.__init__(self, world, obj_data) self.times_fired = 0 # list of objects we've spawned self.spawned_objects = [] - + def get_spawn_class_name(self): "Return class name of object to spawn." return self.spawn_class_name - + def get_spawn_location(self): "Return x,y location we should spawn a new object at." if not self.spawn_random_in_bounds: @@ -358,11 +431,11 @@ class ObjectSpawner(LocationMarker): x = left + random.random() * (right - left) y = top + random.random() * (bottom - top) return x, y - + def can_spawn(self): "Return True if spawner is allowed to spawn." return True - + def do_spawn(self): "Spawn and returns object." class_name = self.get_spawn_class_name() @@ -379,7 +452,7 @@ class ObjectSpawner(LocationMarker): new_obj.spawner = self # TODO: put new object in our room(s), apply spawn_obj_data return new_obj - + def trigger(self): "Poke this spawner to do its thing, returns an object if spawned" if self.times_to_fire != -1 and self.times_fired >= self.times_to_fire: @@ -389,11 +462,11 @@ class ObjectSpawner(LocationMarker): if self.times_fired != -1: self.times_fired += 1 return self.do_spawn() - + def room_entered(self, room, old_room): if self.trigger_on_room_enter: self.trigger() - + def room_exited(self, room, new_room): if not self.destroy_on_room_exit: return @@ -403,29 +476,38 @@ class ObjectSpawner(LocationMarker): class SoundBlaster(LocationMarker): "Simple object that plays sound when triggered" + is_debug = True - sound_name = '' + sound_name = "" "String name of sound to play, minus any extension" can_play = True "If False, won't play sound when triggered" play_on_room_enter = True loops = -1 "Number of times to loop, if -1 loop indefinitely" - serialized = LocationMarker.serialized + ['sound_name', 'can_play', - 'play_on_room_enter'] - + serialized = LocationMarker.serialized + [ + "sound_name", + "can_play", + "play_on_room_enter", + ] + def __init__(self, world, obj_data=None): LocationMarker.__init__(self, world, obj_data) # find file, try common extensions - for ext in ['', '.ogg', '.wav']: + for ext in ["", ".ogg", ".wav"]: filename = self.sound_name + ext - if self.world.sounds_dir and os.path.exists(self.world.sounds_dir + filename): + if self.world.sounds_dir and os.path.exists( + self.world.sounds_dir + filename + ): self.sound_filenames[self.sound_name] = filename return - self.world.app.log("Couldn't find sound file %s for SoundBlaster %s" % (self.sound_name, self.name)) - + self.world.app.log( + "Couldn't find sound file %s for SoundBlaster %s" + % (self.sound_name, self.name) + ) + def room_entered(self, room, old_room): self.play_sound(self.sound_name, self.loops) - + def room_exited(self, room, new_room): self.stop_sound(self.sound_name) diff --git a/game_world.py b/game_world.py index e8a8a7b..3e435c4 100644 --- a/game_world.py +++ b/game_world.py @@ -1,22 +1,31 @@ - -import os, sys, math, time, importlib, json, traceback +import importlib +import json +import math +import os +import sys +import time +import traceback from collections import namedtuple import sdl2 -import game_object, game_util_objects, game_hud, game_room -import collision, vector -from camera import Camera -from grid import GameGrid +import collision +import game_hud +import game_object +import game_room +import game_util_objects +import vector from art import ART_DIR +from camera import Camera from charset import CHARSET_DIR +from grid import GameGrid from palette import PALETTE_DIR -TOP_GAME_DIR = 'games/' -DEFAULT_STATE_FILENAME = 'start' -STATE_FILE_EXTENSION = 'gs' -GAME_SCRIPTS_DIR = 'scripts/' -SOUNDS_DIR = 'sounds/' +TOP_GAME_DIR = "games/" +DEFAULT_STATE_FILENAME = "start" +STATE_FILE_EXTENSION = "gs" +GAME_SCRIPTS_DIR = "scripts/" +SOUNDS_DIR = "sounds/" # generic starter script with a GO and Player subclass STARTER_SCRIPT = """ @@ -43,37 +52,39 @@ class MyGameObject(GameObject): # Quickie class to debug render order -RenderItem = namedtuple('RenderItem', ['obj', 'layer', 'sort_value']) +RenderItem = namedtuple("RenderItem", ["obj", "layer", "sort_value"]) class GameCamera(Camera): pan_friction = 0.2 use_bounds = False + class GameWorld: """ Holds global state for Game Mode. Spawns, manages, and renders GameObjects. Properties serialized via WorldPropertiesObject. Global state can be controlled via a WorldGlobalsObject. """ - game_title = 'Untitled Game' + + game_title = "Untitled Game" "Title for game, shown in window titlebar when not editing" - gravity_x, gravity_y, gravity_z = 0., 0., 0. + gravity_x, gravity_y, gravity_z = 0.0, 0.0, 0.0 "Gravity applied to all objects who are affected by gravity." - bg_color = [0., 0., 0., 1.] + bg_color = [0.0, 0.0, 0.0, 1.0] "OpenGL wiewport color to render behind everything else, ie the void." - hud_class_name = 'GameHUD' + hud_class_name = "GameHUD" "String name of HUD class to use" - properties_object_class_name = 'WorldPropertiesObject' - globals_object_class_name = 'WorldGlobalsObject' + properties_object_class_name = "WorldPropertiesObject" + globals_object_class_name = "WorldGlobalsObject" "String name of WorldGlobalsObject class to use." player_camera_lock = True "If True, camera will be locked to player's location." object_grid_snap = True # editable properties # TODO: - #update_when_unfocused = False - #"If True, game sim will update even when window doesn't have input focus" + # update_when_unfocused = False + # "If True, game sim will update even when window doesn't have input focus" draw_hud = True allow_pause = True "If False, user cannot pause game sim" @@ -91,11 +102,13 @@ class GameWorld: "If True, snap camera to new room's associated camera marker." list_only_current_room_objects = False "If True, list UI will only show objects in current room." - builtin_module_names = ['game_object', 'game_util_objects', 'game_hud', - 'game_room'] - builtin_base_classes = (game_object.GameObject, game_hud.GameHUD, - game_room.GameRoom) - + builtin_module_names = ["game_object", "game_util_objects", "game_hud", "game_room"] + builtin_base_classes = ( + game_object.GameObject, + game_hud.GameHUD, + game_room.GameRoom, + ) + def __init__(self, app): self.app = app "Application that created this world." @@ -122,9 +135,12 @@ class GameWorld: self._pause_time = 0 self.updates = 0 "Number of updates this we have performed." - self.modules = {'game_object': game_object, - 'game_util_objects': game_util_objects, - 'game_hud': game_hud, 'game_room': game_room} + self.modules = { + "game_object": game_object, + "game_util_objects": game_util_objects, + "game_hud": game_hud, + "game_room": game_room, + } self.classname_to_spawn = None self.objects = {} "Dict of objects by name:object" @@ -139,27 +155,27 @@ class GameWorld: self.drag_objects = {} "Offsets for objects player is edit-dragging" self.last_state_loaded = DEFAULT_STATE_FILENAME - + def play_music(self, music_filename, fade_in_time=0): "Play given music file in any SDL2_mixer-supported format." music_filename = self.game_dir + SOUNDS_DIR + music_filename self.app.al.set_music(music_filename) self.app.al.start_music(music_filename) - + def pause_music(self): self.app.al.pause_music() - + def resume_music(self): self.app.al.resume_music() - + def stop_music(self): "Stop any currently playing music." self.app.al.stop_all_music() - + def is_music_playing(self): "Return True if there is music playing." return self.app.al.is_music_playing() - + def pick_next_object_at(self, x, y): "Return next unselected object at given point." objects = self.get_objects_at(x, y) @@ -167,9 +183,12 @@ class GameWorld: if len(objects) == 0: return None # don't bother cycling if only one object found - if len(objects) == 1 and objects[0].selectable and \ - not objects[0] in self.selected_objects: - return objects[0] + if ( + len(objects) == 1 + and objects[0].selectable + and objects[0] not in self.selected_objects + ): + return objects[0] # cycle through objects at point til an unselected one is found for obj in objects: if not obj.selectable: @@ -178,7 +197,7 @@ class GameWorld: continue return obj return None - + def get_objects_at(self, x, y, allow_locked=False): "Return list of all objects whose bounds fall within given point." objects = [] @@ -192,17 +211,16 @@ class GameWorld: # sort objects in Z, highest first objects.sort(key=lambda obj: obj.z, reverse=True) return objects - + def select_click(self): - x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, - self.app.mouse_y) + x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y) # remember last place we clicked self.last_mouse_click_x, self.last_mouse_click_y = x, y if self.classname_to_spawn: new_obj = self.spawn_object_of_class(self.classname_to_spawn, x, y) if self.current_room: self.current_room.add_object(new_obj) - self.app.ui.message_line.post_line('Spawned %s' % new_obj.name) + self.app.ui.message_line.post_line("Spawned %s" % new_obj.name) return objects = self.get_objects_at(x, y) next_obj = self.pick_next_object_at(x, y) @@ -229,7 +247,7 @@ class GameWorld: # remember object offsets from cursor for dragging for obj in self.selected_objects: self.drag_objects[obj.name] = (obj.x - x, obj.y - y, obj.z - z) - + def clicked(self, button): # if edit UI is up, select stuff if self.app.ui.is_game_edit_ui_visible(): @@ -237,17 +255,19 @@ class GameWorld: self.select_click() # else pass clicks to any objects under mouse else: - x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, - self.app.mouse_y) + x, y, z = vector.screen_to_world( + self.app, self.app.mouse_x, self.app.mouse_y + ) # 'locked" only relevant to edit mode, ignore it if in play mode objects = self.get_objects_at(x, y, allow_locked=True) for obj in objects: - if obj.handle_mouse_events and \ - (not obj.locked or not self.app.can_edit): + if obj.handle_mouse_events and ( + not obj.locked or not self.app.can_edit + ): obj.clicked(button, x, y) if obj.consume_mouse_events: break - + def select_unclick(self): # clicks on UI are consumed and flag world to not accept unclicks # (keeps unclicks after dialog dismiss from deselecting objects) @@ -260,8 +280,7 @@ class GameWorld: # if we're clicking to spawn something, don't drag/select if self.classname_to_spawn: return - x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, - self.app.mouse_y) + x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y) # remember selected objects now, they might be deselected but still # need to have their collision turned back on. selected_objects = self.selected_objects[:] @@ -270,7 +289,7 @@ class GameWorld: # except one mouse is over. dx = self.last_mouse_click_x - x dy = self.last_mouse_click_y - y - if math.sqrt(dx ** 2 + dy ** 2) < 1.5: + if math.sqrt(dx**2 + dy**2) < 1.5: for obj in self.get_objects_at(x, y): if obj in self.selected_objects: self.deselect_all() @@ -283,23 +302,23 @@ class GameWorld: if obj.collision_shape_type == collision.CST_TILE: obj.collision.create_shapes() obj.collision.update() - + def unclicked(self, button): if self.app.ui.is_game_edit_ui_visible(): if button == sdl2.SDL_BUTTON_LEFT: self.select_unclick() else: - x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, - self.app.mouse_y) + x, y, z = vector.screen_to_world( + self.app, self.app.mouse_x, self.app.mouse_y + ) objects = self.get_objects_at(x, y) for obj in objects: if obj.handle_mouse_events: obj.unclicked(button, x, y) - + def check_hovers(self): "Update objects on their mouse hover status" - x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, - self.app.mouse_y) + x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y) new_hovers = self.get_objects_at(x, y) # if this object will be selected on left click; draw bounds & label if self.app.ui.is_game_edit_ui_visible(): @@ -308,31 +327,32 @@ class GameWorld: # if in play mode, notify objects who have begun to be hovered else: for obj in new_hovers: - if obj.handle_mouse_events and not obj in self.hovered_objects: + if obj.handle_mouse_events and obj not in self.hovered_objects: obj.hovered(x, y) # check for objects un-hovered by this move for obj in self.hovered_objects: - if obj.handle_mouse_events and not obj in new_hovers: + if obj.handle_mouse_events and obj not in new_hovers: obj.unhovered(x, y) self.hovered_objects = new_hovers - + def mouse_wheeled(self, wheel_y): - x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, - self.app.mouse_y) + x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y) objects = self.get_objects_at(x, y, allow_locked=True) for obj in objects: - if obj.handle_mouse_events and \ - (not obj.locked or not self.app.can_edit): + if obj.handle_mouse_events and (not obj.locked or not self.app.can_edit): obj.mouse_wheeled(wheel_y) if obj.consume_mouse_events: break - + def mouse_moved(self, dx, dy): if self.app.ui.active_dialog: return # bail if mouse didn't move (in world space - include camera) last input - if self.app.mouse_dx == 0 and self.app.mouse_dy == 0 and \ - not self.camera.moved_this_frame: + if ( + self.app.mouse_dx == 0 + and self.app.mouse_dy == 0 + and not self.camera.moved_this_frame + ): return # if last onclick was a UI element, don't drag if self.last_click_on_ui: @@ -345,9 +365,8 @@ class GameWorld: if dx == 0 and dy == 0: return # set dragged objects to mouse + offset from mouse when drag started - x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, - self.app.mouse_y) - for obj_name,offset in self.drag_objects.items(): + x, y, z = vector.screen_to_world(self.app, self.app.mouse_x, self.app.mouse_y) + for obj_name, offset in self.drag_objects.items(): obj = self.objects[obj_name] if obj.locked: continue @@ -364,32 +383,32 @@ class GameWorld: obj.x += obj.art.quad_width / 2 if obj.art.height % 2 != 0: obj.y += obj.art.quad_height / 2 - + def select_object(self, obj, force=False): "Add given object to our list of selected objects." if not self.app.can_edit: return - if obj and (obj.selectable or force) and not obj in self.selected_objects: + if obj and (obj.selectable or force) and obj not in self.selected_objects: self.selected_objects.append(obj) self.app.ui.object_selection_changed() - + def deselect_object(self, obj): "Remove given object from our list of selected objects." if obj in self.selected_objects: self.selected_objects.remove(obj) self.app.ui.object_selection_changed() - + def deselect_all(self): "Deselect all objects." self.selected_objects = [] self.app.ui.object_selection_changed() - + def create_new_game(self, new_game_dir, new_game_title): "Create appropriate dirs and files for a new game, return success." self.unload_game() - new_dir = self.app.documents_dir + TOP_GAME_DIR + new_game_dir + '/' + new_dir = self.app.documents_dir + TOP_GAME_DIR + new_game_dir + "/" if os.path.exists(new_dir): - self.app.log('Game dir %s already exists!' % new_game_dir) + self.app.log("Game dir %s already exists!" % new_game_dir) return False os.mkdir(new_dir) os.mkdir(new_dir + ART_DIR) @@ -398,12 +417,12 @@ class GameWorld: os.mkdir(new_dir + CHARSET_DIR) os.mkdir(new_dir + PALETTE_DIR) # create a generic starter script with a GO and Player subclass - f = open(new_dir + GAME_SCRIPTS_DIR + new_game_dir + '.py', 'w') + f = open(new_dir + GAME_SCRIPTS_DIR + new_game_dir + ".py", "w") f.write(STARTER_SCRIPT) f.close() # load game self.set_game_dir(new_game_dir) - self.properties = self.spawn_object_of_class('WorldPropertiesObject') + self.properties = self.spawn_object_of_class("WorldPropertiesObject") self.objects.update(self.new_objects) self.new_objects = {} # HACK: set some property defaults, no idea why they don't take :[ @@ -411,7 +430,7 @@ class GameWorld: self.game_title = self.properties.game_title = new_game_title self.save_to_file(DEFAULT_STATE_FILENAME) return True - + def unload_game(self): "Unload currently loaded game." for obj in self.objects.values(): @@ -429,7 +448,7 @@ class GameWorld: # art_loaded is cleared when game dir is set self.selected_objects = [] self.app.al.stop_all_music() - + def get_first_object_of_type(self, class_name, allow_subclasses=True): "Return first object found with given class name." c = self.get_class_by_name(class_name) @@ -440,7 +459,7 @@ class GameWorld: return obj elif type(obj).__name__ == class_name: return obj - + def get_all_objects_of_type(self, class_name, allow_subclasses=True): "Return list of all objects found with given class name." c = self.get_class_by_name(class_name) @@ -452,12 +471,12 @@ class GameWorld: elif type(obj).__name__ == class_name: objects.append(obj) return objects - + def set_for_all_objects(self, name, value): "Set given variable name to given value for all objects." for obj in self.objects.values(): setattr(obj, name, value) - + def edit_art_for_selected(self): if len(self.selected_objects) == 0: return @@ -465,19 +484,19 @@ class GameWorld: for obj in self.selected_objects: for art_filename in obj.get_all_art(): self.app.load_art_for_edit(art_filename) - + def move_selected(self, move_x, move_y, move_z): "Shift position of selected objects by given x,y,z amount." for obj in self.selected_objects: obj.x += move_x obj.y += move_y obj.z += move_z - + def reset_game(self): "Reset currently loaded game to last loaded state." if self.game_dir: self.load_game_state(self.last_state_loaded) - + def set_game_dir(self, dir_name, reset=False): "Load game from given game directory." if self.game_dir and dir_name == self.game_dir: @@ -493,9 +512,9 @@ class GameWorld: continue self.game_dir = d self.game_name = dir_name - if not d.endswith('/'): - self.game_dir += '/' - self.app.log('Game data folder is now %s' % self.game_dir) + if not d.endswith("/"): + self.game_dir += "/" + self.app.log("Game data folder is now %s" % self.game_dir) # set sounds dir before loading state; some obj inits depend on it self.sounds_dir = self.game_dir + SOUNDS_DIR if reset: @@ -508,52 +527,56 @@ class GameWorld: break if not self.game_dir: self.app.log("Couldn't find game directory %s" % dir_name) - + def _remove_non_current_game_modules(self): """ Remove modules from previously-loaded games from both sys and GameWorld's dicts. """ modules_to_remove = [] - games_dir_prefix = TOP_GAME_DIR.replace('/', '') - this_game_dir_prefix = '%s.%s' % (games_dir_prefix, self.game_name) + games_dir_prefix = TOP_GAME_DIR.replace("/", "") + this_game_dir_prefix = "%s.%s" % (games_dir_prefix, self.game_name) for module_name in sys.modules: # remove any module that isn't for this game or part of its path - if module_name != games_dir_prefix and \ - module_name != this_game_dir_prefix and \ - module_name.startswith(games_dir_prefix) and \ - not module_name.startswith(this_game_dir_prefix + '.'): + if ( + module_name != games_dir_prefix + and module_name != this_game_dir_prefix + and module_name.startswith(games_dir_prefix) + and not module_name.startswith(this_game_dir_prefix + ".") + ): modules_to_remove.append(module_name) for module_name in modules_to_remove: sys.modules.pop(module_name) if module_name in self.modules: self.modules.pop(module_name) - + def _get_game_modules_list(self): "Return list of current game's modules from its scripts/ dir" # build list of module files modules_list = self.builtin_module_names[:] # create appropriately-formatted python import path - module_path_prefix = '%s.%s.%s.' % (TOP_GAME_DIR.replace('/', ''), - self.game_name, - GAME_SCRIPTS_DIR.replace('/', '')) + module_path_prefix = "%s.%s.%s." % ( + TOP_GAME_DIR.replace("/", ""), + self.game_name, + GAME_SCRIPTS_DIR.replace("/", ""), + ) for filename in os.listdir(self.game_dir + GAME_SCRIPTS_DIR): # exclude emacs temp files and special world start script - if not filename.endswith('.py'): + if not filename.endswith(".py"): continue - if filename.startswith('.#'): + if filename.startswith(".#"): continue - new_module_name = module_path_prefix + filename.replace('.py', '') + new_module_name = module_path_prefix + filename.replace(".py", "") modules_list.append(new_module_name) return modules_list - + def _import_all(self): """ Populate GameWorld.modules with the modules GW._get_all_loaded_classes refers to when finding classes to spawn. """ # on first load, documents dir may not be in import path - if not self.app.documents_dir in sys.path: + if self.app.documents_dir not in sys.path: sys.path += [self.app.documents_dir] # clean modules dict before (re)loading anything self._remove_non_current_game_modules() @@ -564,52 +587,54 @@ class GameWorld: for module_name in self._get_game_modules_list(): try: # always reload built in modules - if module_name in self.builtin_module_names or \ - module_name in old_modules: + if ( + module_name in self.builtin_module_names + or module_name in old_modules + ): m = importlib.reload(old_modules[module_name]) else: m = importlib.import_module(module_name) self.modules[module_name] = m except Exception as e: self.app.log_import_exception(e, module_name) - + def toggle_pause(self): "Toggles game pause state." if not self.allow_pause: return self.paused = not self.paused - s = 'Game %spaused.' % ['un', ''][self.paused] + s = "Game %spaused." % ["un", ""][self.paused] self.app.ui.message_line.post_line(s) - + def get_elapsed_time(self): """ Return total time world has been running (ie not paused) in milliseconds. """ return self.app.get_elapsed_time() - self._pause_time - + def enable_player_camera_lock(self): if self.player: self.camera.focus_object = self.player - + def disable_player_camera_lock(self): # change only if player has focus if self.player and self.camera.focus_object is self.player: self.camera.focus_object = None - + def toggle_player_camera_lock(self): "Toggle whether camera is locked to player's location." if self.player and self.camera.focus_object is self.player: self.disable_player_camera_lock() else: self.enable_player_camera_lock() - + def toggle_grid_snap(self): self.object_grid_snap = not self.object_grid_snap - + def handle_input(self, event, shift_pressed, alt_pressed, ctrl_pressed): # pass event's key to any objects that want to handle it - if not event.type in [sdl2.SDL_KEYDOWN, sdl2.SDL_KEYUP]: + if event.type not in [sdl2.SDL_KEYDOWN, sdl2.SDL_KEYUP]: return key = sdl2.SDL_GetKeyName(event.key.keysym.sym).decode() key = key.lower() @@ -621,12 +646,16 @@ class GameWorld: elif event.type == sdl2.SDL_KEYUP: self.try_object_method(obj, obj.handle_key_up, args) # TODO: handle_ functions for other types of input - - def get_colliders_at_point(self, point_x, point_y, - include_object_names=[], - include_class_names=[], - exclude_object_names=[], - exclude_class_names=[]): + + def get_colliders_at_point( + self, + point_x, + point_y, + include_object_names=[], + include_class_names=[], + exclude_object_names=[], + exclude_class_names=[], + ): """ Return lists of colliding objects and shapes at given point that pass given filters. @@ -645,28 +674,34 @@ class GameWorld: check_objects = [] if whitelist_objects or whitelist_classes: # list of class names -> list of classes - include_classes = [self.get_class_by_name(class_name) for class_name in include_class_names] + include_classes = [ + self.get_class_by_name(class_name) for class_name in include_class_names + ] # only given objects of given classes if whitelist_objects and whitelist_classes: for obj_name in include_object_names: obj = self.objects[obj_name] for c in include_classes: - if isinstance(obj, c) and not obj in check_objects: + if isinstance(obj, c) and obj not in check_objects: check_objects.append(obj) # only given objects of any class elif whitelist_objects and not whitelist_classes: - check_objects += [self.objects[obj_name] for obj_name in include_object_names] + check_objects += [ + self.objects[obj_name] for obj_name in include_object_names + ] # all colliders of given classes elif whitelist_classes: for obj in colliders: for c in include_classes: - if isinstance(obj, c) and not obj in check_objects: + if isinstance(obj, c) and obj not in check_objects: check_objects.append(obj) else: check_objects = colliders[:] check_objects_unfiltered = check_objects[:] if blacklist_objects or blacklist_classes: - exclude_classes = [self.get_class_by_name(class_name) for class_name in exclude_class_names] + exclude_classes = [ + self.get_class_by_name(class_name) for class_name in exclude_class_names + ] for obj in check_objects_unfiltered: if obj.name in exclude_object_names: check_objects.remove(obj) @@ -694,34 +729,38 @@ class GameWorld: hit_objects.append(obj) hit_shapes.append(shape) return hit_objects, hit_shapes - + def frame_begin(self): "Run at start of game loop iteration, before input/update/render." for obj in self.objects.values(): obj.art.updated_this_tick = False obj.frame_begin() - + def frame_update(self): for obj in self.objects.values(): obj.frame_update() - + def try_object_method(self, obj, method, args=()): "Try to run given object's given method, printing error if encountered." - #print('running %s.%s' % (obj.name, method.__name__)) + # print('running %s.%s' % (obj.name, method.__name__)) try: method(*args) - if method.__name__ == 'update': + if method.__name__ == "update": obj.last_update_failed = False - except Exception as e: - if method.__name__ == 'update' and obj.last_update_failed: + except Exception: + if method.__name__ == "update" and obj.last_update_failed: return obj.last_update_failed = True - for line in traceback.format_exc().split('\n'): - if line and not 'try_object_method' in line and line.strip() != 'method()': + for line in traceback.format_exc().split("\n"): + if ( + line + and "try_object_method" not in line + and line.strip() != "method()" + ): self.app.log(line.rstrip()) - s = 'Error in %s.%s! See console.' % (obj.name, method.__name__) + s = "Error in %s.%s! See console." % (obj.name, method.__name__) self.app.ui.message_line.post_line(s, 10, True) - + def pre_update(self): "Run GO and Room pre_updates before GameWorld.update" # add newly spawned objects to table @@ -733,7 +772,9 @@ class GameWorld: self.try_object_method(obj, obj.pre_first_update) obj.pre_first_update_run = True # only run pre_update if not paused - elif not self.paused and (obj.is_in_current_room() or obj.update_if_outside_room): + elif not self.paused and ( + obj.is_in_current_room() or obj.update_if_outside_room + ): # update timers # (copy timers list in case a timer removes itself from object) for timer in list(obj.timer_functions_pre_update.values())[:]: @@ -743,7 +784,7 @@ class GameWorld: if not room.pre_first_update_run: room.pre_first_update() room.pre_first_update_run = True - + def update(self): self.mouse_moved(self.app.mouse_dx, self.app.mouse_dy) if self.properties: @@ -783,7 +824,7 @@ class GameWorld: self._pause_time += self.app.get_elapsed_time() - self.app.last_time else: self.updates += 1 - + def post_update(self): "Run after GameWorld.update." if self.paused: @@ -794,7 +835,7 @@ class GameWorld: for timer in list(obj.timer_functions_post_update.values())[:]: timer.update() obj.post_update() - + def render(self): "Sort and draw all objects in Game Mode world." visible_objects = [] @@ -807,8 +848,7 @@ class GameWorld: in_room = self.current_room is None or obj.is_in_current_room() hide_debug = obj.is_debug and not self.draw_debug_objects # respect object's "should render at all" flag - if obj.visible and not hide_debug and \ - (self.show_all_rooms or in_room): + if obj.visible and not hide_debug and (self.show_all_rooms or in_room): visible_objects.append(obj) # # process non "Y sort" objects first @@ -820,15 +860,17 @@ class GameWorld: if obj.y_sort: y_objects.append(obj) continue - for i,z in enumerate(obj.art.layers_z): + for i, z in enumerate(obj.art.layers_z): # ignore invisible layers if not obj.art.layers_visibility[i]: continue # only draw collision layer if show collision is set, OR if # "draw collision layer" is set - if obj.collision_shape_type == collision.CST_TILE and \ - obj.col_layer_name == obj.art.layer_names[i] and \ - not obj.draw_col_layer: + if ( + obj.collision_shape_type == collision.CST_TILE + and obj.col_layer_name == obj.art.layer_names[i] + and not obj.draw_col_layer + ): if obj.show_collision: item = RenderItem(obj, i, z + obj.z) collision_items.append(item) @@ -843,11 +885,13 @@ class GameWorld: # draw layers of each Y-sorted object in Z order for obj in y_objects: items = [] - for i,z in enumerate(obj.art.layers_z): + for i, z in enumerate(obj.art.layers_z): if not obj.art.layers_visibility[i]: continue - if obj.collision_shape_type == collision.CST_TILE and \ - obj.col_layer_name == obj.art.layer_names[i]: + if ( + obj.collision_shape_type == collision.CST_TILE + and obj.col_layer_name == obj.art.layer_names[i] + ): if obj.show_collision: item = RenderItem(obj, i, 0) collision_items.append(item) @@ -869,7 +913,7 @@ class GameWorld: obj.render_debug() if self.hud and self.draw_hud: self.hud.render() - + def save_last_state(self): "Save over last loaded state." # strip down to base filename w/o extension :/ @@ -877,56 +921,58 @@ class GameWorld: last_state = os.path.basename(last_state) last_state = os.path.splitext(last_state)[0] self.save_to_file(last_state) - + def save_to_file(self, filename=None): "Save current world state to a file." objects = [] for obj in self.objects.values(): if obj.should_save: objects.append(obj.get_dict()) - d = {'objects': objects} + d = {"objects": objects} # save rooms if any exist if len(self.rooms) > 0: rooms = [room.get_dict() for room in self.rooms.values()] - d['rooms'] = rooms + d["rooms"] = rooms if self.current_room: - d['current_room'] = self.current_room.name - if filename and filename != '': + d["current_room"] = self.current_room.name + if filename and filename != "": if not filename.endswith(STATE_FILE_EXTENSION): - filename += '.' + STATE_FILE_EXTENSION - filename = '%s%s' % (self.game_dir, filename) + filename += "." + STATE_FILE_EXTENSION + filename = "%s%s" % (self.game_dir, filename) else: # state filename example: # games/mytestgame2/1431116386.gs timestamp = int(time.time()) - filename = '%s%s.%s' % (self.game_dir, timestamp, - STATE_FILE_EXTENSION) - json.dump(d, open(filename, 'w'), - sort_keys=True, indent=1) - self.app.log('Saved game state %s to disk.' % filename) + filename = "%s%s.%s" % (self.game_dir, timestamp, STATE_FILE_EXTENSION) + json.dump(d, open(filename, "w"), sort_keys=True, indent=1) + self.app.log("Saved game state %s to disk." % filename) self.app.update_window_title() - + def _get_all_loaded_classes(self): """ Return classname,class dict of all GameObject classes in loaded modules. """ classes = {} for module in self.modules.values(): - for k,v in module.__dict__.items(): + for k, v in module.__dict__.items(): # skip anything that's not a game class - if not type(v) is type: + if type(v) is not type: continue - base_classes = (game_object.GameObject, game_hud.GameHUD, game_room.GameRoom) + base_classes = ( + game_object.GameObject, + game_hud.GameHUD, + game_room.GameRoom, + ) # TODO: find out why above works but below doesn't!! O___O - #base_classes = self.builtin_base_classes + # base_classes = self.builtin_base_classes if issubclass(v, base_classes): classes[k] = v return classes - + def get_class_by_name(self, class_name): "Return Class object for given class name." return self.classes.get(class_name, None) - + def reset_object_in_place(self, obj): """ "Reset" given object to its class defaults. @@ -936,11 +982,11 @@ class GameWorld: obj_class = obj.__class__.__name__ spawned = self.spawn_object_of_class(obj_class, x, y) if spawned: - self.app.log('%s reset to class defaults' % obj.name) + self.app.log("%s reset to class defaults" % obj.name) if obj is self.player: self.player = spawned obj.destroy() - + def duplicate_selected_objects(self): "Duplicate all selected objects. Calls GW.duplicate_object" new_objects = [] @@ -948,21 +994,21 @@ class GameWorld: new_objects.append(self.duplicate_object(obj)) # report on objects created if len(new_objects) == 1: - self.app.log('%s created from %s' % (new_objects[0].name, obj.name)) + self.app.log("%s created from %s" % (new_objects[0].name, obj.name)) elif len(new_objects) > 1: - self.app.log('%s new objects created' % len(new_objects)) - + self.app.log("%s new objects created" % len(new_objects)) + def duplicate_object(self, obj): "Create a duplicate of given object." d = obj.get_dict() # offset new object's location - x, y = d['x'], d['y'] + x, y = d["x"], d["y"] x += obj.renderable.width y -= obj.renderable.height - d['x'], d['y'] = x, y + d["x"], d["y"] = x, y # new object needs a unique name, use a temp one until object exists # for real and we can give it a proper, more-likely-to-be-unique one - d['name'] = obj.name + ' TEMP COPY NAME' + d["name"] = obj.name + " TEMP COPY NAME" new_obj = self.spawn_object_from_data(d) # give object a non-duplicate name self.rename_object(new_obj, new_obj.get_unique_name()) @@ -972,13 +1018,15 @@ class GameWorld: # update list after changes have been applied to object self.app.ui.edit_list_panel.items_changed() return new_obj - + def rename_object(self, obj, new_name): "Give specified object a new name. Doesn't accept already-in-use names." self.objects.update(self.new_objects) for other_obj in self.objects.values(): - if not other_obj is self and other_obj.name == new_name: - self.app.ui.message_line.post_line("Can't rename %s to %s, name already in use" % (obj.name, new_name)) + if other_obj is not self and other_obj.name == new_name: + self.app.ui.message_line.post_line( + "Can't rename %s to %s, name already in use" % (obj.name, new_name) + ) return self.objects.pop(obj.name) old_name = obj.name @@ -988,54 +1036,54 @@ class GameWorld: if obj in room.objects.values(): room.objects.pop(old_name) room.objects[obj.name] = self - + def spawn_object_of_class(self, class_name, x=None, y=None): "Spawn a new object of given class name at given location." - if not class_name in self.classes: + if class_name not in self.classes: # no need for log here, import_all prints exception cause - #self.app.log("Couldn't find class %s" % class_name) + # self.app.log("Couldn't find class %s" % class_name) return - d = {'class_name': class_name} + d = {"class_name": class_name} if x is not None and y is not None: - d['x'], d['y'] = x, y + d["x"], d["y"] = x, y new_obj = self.spawn_object_from_data(d) self.app.ui.edit_list_panel.items_changed() return new_obj - + def spawn_object_from_data(self, object_data): "Spawn a new object with properties populated from given data dict." # load module and class - class_name = object_data.get('class_name', None) - if not class_name or not class_name in self.classes: + class_name = object_data.get("class_name", None) + if not class_name or class_name not in self.classes: # no need for log here, import_all prints exception cause - #self.app.log("Couldn't parse class %s" % class_name) + # self.app.log("Couldn't parse class %s" % class_name) return obj_class = self.classes[class_name] # pass in object data new_object = obj_class(self, object_data) return new_object - - def add_room(self, new_room_name, new_room_classname='GameRoom'): + + def add_room(self, new_room_name, new_room_classname="GameRoom"): "Add a new Room with given name of (optional) given class." if new_room_name in self.rooms: - self.log('Room called %s already exists!' % new_room_name) + self.log("Room called %s already exists!" % new_room_name) return new_room_class = self.classes[new_room_classname] new_room = new_room_class(self, new_room_name) self.rooms[new_room.name] = new_room - + def remove_room(self, room_name): "Delete Room with given name." - if not room_name in self.rooms: + if room_name not in self.rooms: return room = self.rooms.pop(room_name) if room is self.current_room: self.current_room = None room.destroy() - + def change_room(self, new_room_name): "Set world's current active room to Room with given name." - if not new_room_name in self.rooms: + if new_room_name not in self.rooms: self.app.log("Couldn't change to missing room %s" % new_room_name) return old_room = self.current_room @@ -1044,7 +1092,7 @@ class GameWorld: if old_room: old_room.exited(self.current_room) self.current_room.entered(old_room) - + def rename_room(self, room, new_room_name): "Rename given Room to new given name." old_name = room.name @@ -1056,13 +1104,13 @@ class GameWorld: if old_name in obj.rooms: obj.rooms.pop(old_name) obj.rooms[new_room_name] = room - + def load_game_state(self, filename=DEFAULT_STATE_FILENAME): "Load game state with given filename." if not os.path.exists(filename): filename = self.game_dir + filename if not filename.endswith(STATE_FILE_EXTENSION): - filename += '.' + STATE_FILE_EXTENSION + filename += "." + STATE_FILE_EXTENSION self.app.enter_game_mode() self.unload_game() # tell list panel to reset, its contents might get jostled @@ -1072,14 +1120,14 @@ class GameWorld: self.classes = self._get_all_loaded_classes() try: d = json.load(open(filename)) - #self.app.log('Loading game state %s...' % filename) + # self.app.log('Loading game state %s...' % filename) except: self.app.log("Couldn't load game state from %s" % filename) - #self.app.log(sys.exc_info()) + # self.app.log(sys.exc_info()) return errors = False # spawn objects - for obj_data in d['objects']: + for obj_data in d["objects"]: obj = self.spawn_object_from_data(obj_data) if not obj: errors = True @@ -1089,44 +1137,46 @@ class GameWorld: self.properties = obj break if not self.properties: - self.properties = self.spawn_object_of_class(self.properties_object_class_name, 0, 0) + self.properties = self.spawn_object_of_class( + self.properties_object_class_name, 0, 0 + ) # spawn a WorldGlobalStateObject self.globals = self.spawn_object_of_class(self.globals_object_class_name, 0, 0) # just for first update, merge new objects list into objects list self.objects.update(self.new_objects) # create rooms - for room_data in d.get('rooms', []): + for room_data in d.get("rooms", []): # get room class - room_class_name = room_data.get('class_name', None) + room_class_name = room_data.get("class_name", None) room_class = self.classes.get(room_class_name, game_room.GameRoom) - room = room_class(self, room_data['name'], room_data) + room = room_class(self, room_data["name"], room_data) self.rooms[room.name] = room - start_room = self.rooms.get(d.get('current_room', None), None) + start_room = self.rooms.get(d.get("current_room", None), None) if start_room: self.change_room(start_room.name) # spawn hud - hud_class = self.classes[d.get('hud_class', self.hud_class_name)] + hud_class = self.classes[d.get("hud_class", self.hud_class_name)] self.hud = hud_class(self) self.hud_class_name = hud_class.__name__ if not errors and self.app.init_success: - self.app.log('Loaded game state from %s' % filename) + self.app.log("Loaded game state from %s" % filename) self.last_state_loaded = filename - self.set_for_all_objects('show_collision', self.show_collision_all) - self.set_for_all_objects('show_bounds', self.show_bounds_all) - self.set_for_all_objects('show_origin', self.show_origin_all) + self.set_for_all_objects("show_collision", self.show_collision_all) + self.set_for_all_objects("show_bounds", self.show_bounds_all) + self.set_for_all_objects("show_origin", self.show_origin_all) self.app.update_window_title() self.app.ui.edit_list_panel.items_changed() - #self.report() - + # self.report() + def report(self): "Print (not log) information about current world state." - print('--------------\n%s report:' % self) + print("--------------\n%s report:" % self) obj_arts, obj_rends, obj_dbg_rends, obj_cols, obj_col_rends = 0, 0, 0, 0, 0 attachments = 0 # create merged dict of existing and just-spawned objects all_objects = self.objects.copy() all_objects.update(self.new_objects) - print('%s objects:' % len(all_objects)) + print("%s objects:" % len(all_objects)) for obj in all_objects.values(): obj_arts += len(obj.arts) if obj.renderable is not None: @@ -1139,35 +1189,47 @@ class GameWorld: obj_cols += 1 obj_col_rends += len(obj.collision.renderables) attachments += len(obj.attachments) - print(""" + print( + """ %s arts in objects, %s arts loaded, %s HUD arts, %s HUD renderables, %s renderables, %s debug renderables, %s collideables, %s collideable viz renderables, - %s attachments""" % (obj_arts, len(self.art_loaded), len(self.hud.arts), - len(self.hud.renderables), - obj_rends, obj_dbg_rends, - obj_cols, obj_col_rends, attachments)) + %s attachments""" + % ( + obj_arts, + len(self.art_loaded), + len(self.hud.arts), + len(self.hud.renderables), + obj_rends, + obj_dbg_rends, + obj_cols, + obj_col_rends, + attachments, + ) + ) self.cl.report() - print('%s charsets loaded, %s palettes' % (len(self.app.charsets), - len(self.app.palettes))) - print('%s arts loaded for edit' % len(self.app.art_loaded_for_edit)) - + print( + "%s charsets loaded, %s palettes" + % (len(self.app.charsets), len(self.app.palettes)) + ) + print("%s arts loaded for edit" % len(self.app.art_loaded_for_edit)) + def toggle_all_origin_viz(self): "Toggle visibility of XYZ markers for all object origins." self.show_origin_all = not self.show_origin_all - self.set_for_all_objects('show_origin', self.show_origin_all) - + self.set_for_all_objects("show_origin", self.show_origin_all) + def toggle_all_bounds_viz(self): "Toggle visibility of boxes for all object bounds." self.show_bounds_all = not self.show_bounds_all - self.set_for_all_objects('show_bounds', self.show_bounds_all) - + self.set_for_all_objects("show_bounds", self.show_bounds_all) + def toggle_all_collision_viz(self): "Toggle visibility of debug lines for all object Collideables." self.show_collision_all = not self.show_collision_all - self.set_for_all_objects('show_collision', self.show_collision_all) - + self.set_for_all_objects("show_collision", self.show_collision_all) + def destroy(self): self.unload_game() self.art_loaded = [] diff --git a/games/crawler/scripts/crawler.py b/games/crawler/scripts/crawler.py index e1109aa..e03726d 100644 --- a/games/crawler/scripts/crawler.py +++ b/games/crawler/scripts/crawler.py @@ -1,4 +1,3 @@ - from game_object import GameObject # initial work: 2019-02-17 and 18 @@ -31,24 +30,45 @@ DIR_NORTH = (0, -1) DIR_SOUTH = (0, 1) DIR_EAST = (1, 0) DIR_WEST = (-1, 0) -LEFT_TURN_DIRS = { DIR_NORTH: DIR_WEST, DIR_WEST: DIR_SOUTH, - DIR_SOUTH: DIR_EAST, DIR_EAST: DIR_NORTH } -RIGHT_TURN_DIRS = { DIR_NORTH: DIR_EAST, DIR_EAST: DIR_SOUTH, - DIR_SOUTH: DIR_WEST, DIR_WEST: DIR_NORTH } -DIR_NAMES = { DIR_NORTH: 'north', DIR_SOUTH: 'south', - DIR_EAST: 'east', DIR_WEST: 'west' } -OPPOSITE_DIRS = { DIR_NORTH: DIR_SOUTH, DIR_SOUTH: DIR_NORTH, - DIR_EAST: DIR_WEST, DIR_WEST: DIR_EAST } +LEFT_TURN_DIRS = { + DIR_NORTH: DIR_WEST, + DIR_WEST: DIR_SOUTH, + DIR_SOUTH: DIR_EAST, + DIR_EAST: DIR_NORTH, +} +RIGHT_TURN_DIRS = { + DIR_NORTH: DIR_EAST, + DIR_EAST: DIR_SOUTH, + DIR_SOUTH: DIR_WEST, + DIR_WEST: DIR_NORTH, +} +DIR_NAMES = {DIR_NORTH: "north", DIR_SOUTH: "south", DIR_EAST: "east", DIR_WEST: "west"} +OPPOSITE_DIRS = { + DIR_NORTH: DIR_SOUTH, + DIR_SOUTH: DIR_NORTH, + DIR_EAST: DIR_WEST, + DIR_WEST: DIR_EAST, +} class CompositeTester(GameObject): # slightly confusing terms here, our "source" will be loaded at runtime - art_src = 'comptest_dest' + art_src = "comptest_dest" use_art_instance = True - + def pre_first_update(self): # load composite source art - comp_src_art = self.app.load_art('comptest_src', False) - self.art.composite_from(comp_src_art, 0, 0, 0, 0, - comp_src_art.width, comp_src_art.height, - 0, 0, 3, 2) + comp_src_art = self.app.load_art("comptest_src", False) + self.art.composite_from( + comp_src_art, + 0, + 0, + 0, + 0, + comp_src_art.width, + comp_src_art.height, + 0, + 0, + 3, + 2, + ) diff --git a/games/crawler/scripts/line_tester.py b/games/crawler/scripts/line_tester.py index ad71edc..9604127 100644 --- a/games/crawler/scripts/line_tester.py +++ b/games/crawler/scripts/line_tester.py @@ -1,10 +1,7 @@ - import vector - from game_object import GameObject from renderable_line import DebugLineRenderable - # stuff for troubleshooting "get tiles intersecting line" etc @@ -14,6 +11,7 @@ class DebugMarker(GameObject): generate_art = True should_save = False alpha = 0.5 + def pre_first_update(self): # red X with yellow background self.art.set_tile_at(0, 0, 0, 0, 24, 3, 8) @@ -24,34 +22,34 @@ class LineTester(GameObject): art_width, art_height = 40, 40 art_off_pct_x, art_off_pct_y = 0.0, 0.0 generate_art = True - + def pre_first_update(self): - self.mark_a = self.world.spawn_object_of_class('DebugMarker', -3, 33) - self.mark_b = self.world.spawn_object_of_class('DebugMarker', -10, 40) + self.mark_a = self.world.spawn_object_of_class("DebugMarker", -3, 33) + self.mark_b = self.world.spawn_object_of_class("DebugMarker", -10, 40) self.z = -0.01 self.world.grid.visible = True self.line = DebugLineRenderable(self.app, self.art) self.line.z = 0.0 self.line.line_width = 3 - + def update(self): GameObject.update(self) # debug line - self.line.set_lines([(self.mark_a.x, self.mark_a.y, 0.0), - (self.mark_b.x, self.mark_b.y, 0.0)]) + self.line.set_lines( + [(self.mark_a.x, self.mark_a.y, 0.0), (self.mark_b.x, self.mark_b.y, 0.0)] + ) # paint tiles under line self.art.clear_frame_layer(0, 0, 7) line_func = vector.get_tiles_along_line line_func = vector.get_tiles_along_integer_line - tiles = line_func(self.mark_a.x, self.mark_a.y, - self.mark_b.x, self.mark_b.y) + tiles = line_func(self.mark_a.x, self.mark_a.y, self.mark_b.x, self.mark_b.y) for tile in tiles: x, y = self.get_tile_at_point(tile[0], tile[1]) char, fg = 1, 6 self.art.set_tile_at(0, 0, x, y, char, fg) - + def render(self, layer, z_override=None): GameObject.render(self, layer, z_override) # TODO not sure why this is necessary, pre_first_update should run before first render(), right? blech - if hasattr(self, 'line') and self.line: + if hasattr(self, "line") and self.line: self.line.render() diff --git a/games/crawler/scripts/player.py b/games/crawler/scripts/player.py index 16f5caa..2c4dc21 100644 --- a/games/crawler/scripts/player.py +++ b/games/crawler/scripts/player.py @@ -1,45 +1,47 @@ - -from game_object import GameObject from game_util_objects import Player - -from games.crawler.scripts.crawler import DIR_NORTH, DIR_SOUTH, DIR_EAST, DIR_WEST, LEFT_TURN_DIRS, RIGHT_TURN_DIRS, DIR_NAMES, OPPOSITE_DIRS +from games.crawler.scripts.crawler import ( + DIR_EAST, + DIR_NAMES, + DIR_NORTH, + DIR_SOUTH, + DIR_WEST, + LEFT_TURN_DIRS, + OPPOSITE_DIRS, + RIGHT_TURN_DIRS, +) class CrawlPlayer(Player): - should_save = False # we are spawned by maze + should_save = False # we are spawned by maze generate_art = True art_width, art_height = 1, 1 - art_charset, art_palette = 'jpetscii', 'c64_pepto' + art_charset, art_palette = "jpetscii", "c64_pepto" art_off_pct_x, art_off_pct_y = 0, 0 # bespoke grid-based movement method physics_move = False handle_key_events = True - + view_range_tiles = 8 - fg_color = 8 # yellow - dir_chars = { DIR_NORTH: 147, - DIR_SOUTH: 163, - DIR_EAST: 181, - DIR_WEST: 180 - } - + fg_color = 8 # yellow + dir_chars = {DIR_NORTH: 147, DIR_SOUTH: 163, DIR_EAST: 181, DIR_WEST: 180} + def pre_first_update(self): Player.pre_first_update(self) # top-down facing self.direction = DIR_NORTH self.maze.update_tile_visibilities() self.art.set_tile_at(0, 0, 0, 0, self.dir_chars[self.direction], self.fg_color) - + def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): # turning? - if key == 'left': + if key == "left": self.direction = LEFT_TURN_DIRS[self.direction] - elif key == 'right': + elif key == "right": self.direction = RIGHT_TURN_DIRS[self.direction] # moving? - elif key == 'up' or key == 'down': + elif key == "up" or key == "down": x, y = self.maze.get_tile_at_point(self.x, self.y) - if key == 'up': + if key == "up": new_x = x + self.direction[0] new_y = y + self.direction[1] else: @@ -48,7 +50,11 @@ class CrawlPlayer(Player): # is move valid? if self.maze.is_tile_solid(new_x, new_y): # TEMP negative feedback - dir_name = DIR_NAMES[self.direction] if key == 'up' else DIR_NAMES[OPPOSITE_DIRS[self.direction]] + dir_name = ( + DIR_NAMES[self.direction] + if key == "up" + else DIR_NAMES[OPPOSITE_DIRS[self.direction]] + ) self.app.log("can't go %s!" % dir_name) else: self.x, self.y = self.maze.x + new_x, self.maze.y - new_y diff --git a/games/crawler/scripts/topview.py b/games/crawler/scripts/topview.py index 45401c3..0bf2815 100644 --- a/games/crawler/scripts/topview.py +++ b/games/crawler/scripts/topview.py @@ -1,36 +1,41 @@ - -from game_object import GameObject -from vector import get_tiles_along_integer_line - from art import TileIter - -from random import randint # DEBUG - -from games.crawler.scripts.crawler import DIR_NORTH, DIR_SOUTH, DIR_EAST, DIR_WEST, LEFT_TURN_DIRS, RIGHT_TURN_DIRS, DIR_NAMES, OPPOSITE_DIRS +from game_object import GameObject +from games.crawler.scripts.crawler import ( + DIR_EAST, + DIR_NORTH, + DIR_SOUTH, + DIR_WEST, +) +from vector import get_tiles_along_integer_line class CrawlTopDownView(GameObject): - art_src = 'maze2' + art_src = "maze2" art_off_pct_x, art_off_pct_y = 0, 0 # we will be modifying this view at runtime so don't write on the source art use_art_instance = True # first character we find with this index will be where we spawn player playerstart_char_index = 147 # undiscovered = player has never seen this tile - undiscovered_color_index = 1 # black + undiscovered_color_index = 1 # black # discovered = player has seen this tile but isn't currently looking at it - discovered_color_index = 12 # dark grey - + discovered_color_index = 12 # dark grey + def pre_first_update(self): # scan art for spot to spawn player player_x, player_y = -1, -1 for frame, layer, x, y in TileIter(self.art): - if self.art.get_char_index_at(frame, layer, x, y) == self.playerstart_char_index: + if ( + self.art.get_char_index_at(frame, layer, x, y) + == self.playerstart_char_index + ): player_x, player_y = self.x + x, self.y - y # clear the tile at this spot in our art self.art.set_char_index_at(frame, layer, x, y, 0) break - self.world.player = self.world.spawn_object_of_class('CrawlPlayer', player_x, player_y) + self.world.player = self.world.spawn_object_of_class( + "CrawlPlayer", player_x, player_y + ) # give player a ref to us self.world.player.maze = self # make a copy of original layer to color for visibility, hide original @@ -41,16 +46,16 @@ class CrawlTopDownView(GameObject): continue # set all tiles undiscovered self.art.set_color_at(0, layer, x, y, self.undiscovered_color_index) - self.art.mark_all_frames_changed() # DEBUG - this fixes the difference in result when use_art_instance=True! why? + self.art.mark_all_frames_changed() # DEBUG - this fixes the difference in result when use_art_instance=True! why? # keep a list of tiles player can see self.player_visible_tiles = [] - + def is_tile_solid(self, x, y): return self.art.get_char_index_at(0, 0, x, y) != 0 - + # world to tile: self.get_tile_at_point(world_x, world_y) # tile to world: self.get_tile_loc(tile_x, tile_y) - + def get_visible_tiles(self, x, y, dir_x, dir_y, tile_range, see_thru_walls=False): "return tiles visible from given point facing given direction" # NOTE: all the calculations here are done in this object's art's tile @@ -72,7 +77,7 @@ class CrawlTopDownView(GameObject): # scan back of frustum tile by tile left to right, # checking each tile hit scan_distance = 0 - scan_length = tile_range * 2 + 1 # TODO make sure this is correct + scan_length = tile_range * 2 + 1 # TODO make sure this is correct while scan_distance < scan_length: scan_x = scan_start_x + (scan_dir_x * scan_distance) scan_y = scan_start_y + (scan_dir_y * scan_distance) @@ -80,17 +85,21 @@ class CrawlTopDownView(GameObject): for tile in hit_tiles: tile_x, tile_y = tile[0], tile[1] # skip out-of-bounds tiles - if 0 > tile_x or tile_x >= self.art.width or \ - 0 > tile_y or tile_y >= self.art.height: + if ( + tile_x < 0 + or tile_x >= self.art.width + or tile_y < 0 + or tile_y >= self.art.height + ): continue # whether this tile is solid or not, we have seen it - if not tile in tiles: + if tile not in tiles: tiles.append((tile_x, tile_y)) if not see_thru_walls and self.is_tile_solid(*tile): break scan_distance += 1 return tiles - + def update_tile_visibilities(self): """ update our art's tile visuals based on what tiles can be, can't be, @@ -99,25 +108,27 @@ class CrawlTopDownView(GameObject): previously_visible_tiles = self.player_visible_tiles[:] p = self.world.player px, py = self.get_tile_at_point(p.x, p.y) - self.player_visible_tiles = self.get_visible_tiles(px, py, - *p.direction, - p.view_range_tiles, - see_thru_walls=False) - #print(self.player_visible_tiles) + self.player_visible_tiles = self.get_visible_tiles( + px, py, *p.direction, p.view_range_tiles, see_thru_walls=False + ) + # print(self.player_visible_tiles) # color currently visible tiles for tile in self.player_visible_tiles: - #print(tile) - if 0 > tile[0] or tile[0] >= self.art.width or \ - 0 > tile[1] or tile[1] >= self.art.height: + # print(tile) + if ( + tile[0] < 0 + or tile[0] >= self.art.width + or tile[1] < 0 + or tile[1] >= self.art.height + ): continue if self.is_tile_solid(*tile): orig_color = self.art.get_fg_color_index_at(0, 0, *tile) self.art.set_color_at(0, 1, *tile, orig_color) else: - #self.art.set_color_at(0, 1, *tile, randint(2, 14)) # DEBUG + # self.art.set_color_at(0, 1, *tile, randint(2, 14)) # DEBUG pass # color "previously seen" tiles for tile in previously_visible_tiles: - if not tile in self.player_visible_tiles and \ - self.is_tile_solid(*tile): + if tile not in self.player_visible_tiles and self.is_tile_solid(*tile): self.art.set_color_at(0, 1, *tile, self.discovered_color_index) diff --git a/games/cronotest/scripts/cronotest_classes.py b/games/cronotest/scripts/cronotest_classes.py index 661c610..23ce551 100644 --- a/games/cronotest/scripts/cronotest_classes.py +++ b/games/cronotest/scripts/cronotest_classes.py @@ -1,31 +1,32 @@ -import math +from game_util_objects import DynamicBoxObject, Pickup, StaticTileObject, TopDownPlayer -from game_util_objects import TopDownPlayer, StaticTileBG, StaticTileObject, DynamicBoxObject, Pickup -from collision import CST_AABB class CronoPlayer(TopDownPlayer): - art_src = 'crono' - + art_src = "crono" + col_radius = 1.5 - + # AABB testing - #collision_shape_type = CST_AABB - #col_offset_x, col_offset_y = 0, 1.25 - + # collision_shape_type = CST_AABB + # col_offset_x, col_offset_y = 0, 1.25 + col_width = 3 col_height = 3 art_off_pct_y = 0.9 + class Chest(DynamicBoxObject): - art_src = 'chest' + art_src = "chest" col_width, col_height = 6, 4 col_offset_y = -0.5 + class Urn(Pickup): - art_src = 'urn' + art_src = "urn" col_radius = 2 art_off_pct_y = 0.85 + class Bed(StaticTileObject): - art_src = 'bed' + art_src = "bed" art_off_pct_x, art_off_pct_y = 0.5, 1 diff --git a/games/fireplace/scripts/fireplace.py b/games/fireplace/scripts/fireplace.py index 5f1bfec..1538344 100644 --- a/games/fireplace/scripts/fireplace.py +++ b/games/fireplace/scripts/fireplace.py @@ -1,4 +1,3 @@ - # PETSCII Fireplace for Playscii # https://jp.itch.io/petscii-fireplace @@ -10,11 +9,12 @@ expensive compared to many old demoscene fire tricks. But it's easy to think abo and tune, which was the right call for a one-day exercise :] """ -import os, webbrowser -from random import random, randint, choice +import os +import webbrowser +from random import choice, randint -from game_object import GameObject from art import TileIter +from game_object import GameObject # # some tuning knobs @@ -28,31 +28,30 @@ SPAWN_MARGIN_X = 8 # each particle's character "decays" towards 0 in random jumps CHAR_DECAY_RATE_MAX = 16 # music is just an OGG file, modders feel free to provide your own in sounds/ -MUSIC_FILENAME = 'music.ogg' -MUSIC_URL = 'http://brotherandroid.com' +MUSIC_FILENAME = "music.ogg" +MUSIC_URL = "http://brotherandroid.com" # random ranges for time in seconds til next message pops up MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX = 300, 600 MESSAGES = [ - 'Happy Holidays', - 'Merry Christmas', - 'Happy New Year', - 'Happy Hanukkah', - 'Happy Kwanzaa', - 'Feliz Navidad', - 'Joyeux Noel' + "Happy Holidays", + "Merry Christmas", + "Happy New Year", + "Happy Hanukkah", + "Happy Kwanzaa", + "Feliz Navidad", + "Joyeux Noel", ] class Fireplace(GameObject): - "The main game object, manages particles, handles input, draws the fire." - + generate_art = True - art_charset = 'c64_petscii' - art_width, art_height = 54, 30 # approximately 16x9 aspect - art_palette = 'fireplace' + art_charset = "c64_petscii" + art_width, art_height = 54, 30 # approximately 16x9 aspect + art_palette = "fireplace" handle_key_events = True - + def pre_first_update(self): self.art.add_layer(z=0.01) self.target_particles = TARGET_PARTICLES_DEFAULT @@ -75,12 +74,12 @@ class Fireplace(GameObject): self.help_screen.z = 1 self.help_screen.set_scale(0.75, 0.75, 1) # start with help screen up, uncomment to hide on start - #self.help_screen.visible = False + # self.help_screen.visible = False # don't bother creating credit screen if no music present self.credit_screen = None self.music_exists = False if os.path.exists(self.world.sounds_dir + MUSIC_FILENAME): - self.app.log('%s found in %s' % (MUSIC_FILENAME, self.world.sounds_dir)) + self.app.log("%s found in %s" % (MUSIC_FILENAME, self.world.sounds_dir)) self.world.play_music(MUSIC_FILENAME) self.music_paused = False self.music_exists = True @@ -89,9 +88,9 @@ class Fireplace(GameObject): self.credit_screen.z = 1.1 self.credit_screen.set_scale(0.75, 0.75, 1) else: - self.app.log('No %s found in %s' % (MUSIC_FILENAME, self.world.sounds_dir)) + self.app.log("No %s found in %s" % (MUSIC_FILENAME, self.world.sounds_dir)) self.set_new_message_time() - + def update(self): # shift messages on layer 2 upward gradually if self.app.frames % 10 == 0: @@ -145,18 +144,22 @@ class Fireplace(GameObject): self.art.set_tile_at(frame, layer, x, y, ch, fg - 1, bg - 1) # draw particles # (looks nicer if we don't clear between frames, actually) - #self.art.clear_frame_layer(0, 0) + # self.art.clear_frame_layer(0, 0) for p in self.particles: - self.art.set_tile_at(0, 0, p.x, p.y, self.weighted_chars[p.char], p.fg, p.bg) + self.art.set_tile_at( + 0, 0, p.x, p.y, self.weighted_chars[p.char], p.fg, p.bg + ) # spawn new particles to maintain target count while len(self.particles) < self.target_particles: p = FireParticle(self) self.particles.append(p) GameObject.update(self) - + def set_new_message_time(self): - self.next_message_time = self.world.get_elapsed_time() / 1000 + randint(MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX) - + self.next_message_time = self.world.get_elapsed_time() / 1000 + randint( + MESSAGE_DELAY_MIN, MESSAGE_DELAY_MAX + ) + def post_new_message(self): msg_text = choice(MESSAGES) x = randint(0, self.art.width - len(msg_text)) @@ -164,45 +167,48 @@ class Fireplace(GameObject): y = randint(int(self.art.height / 2), self.art.height) # write to second layer self.art.write_string(0, 1, x, y, msg_text, randint(12, 16)) - + def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): # in many Playscii games all input goes through the Player object; # here input is handled by this object. - if key == 'escape' and not self.world.app.can_edit: + if key == "escape" and not self.world.app.can_edit: self.world.app.should_quit = True - elif key == 'h': + elif key == "h": self.help_screen.visible = not self.help_screen.visible if self.credit_screen: self.credit_screen.visible = not self.credit_screen.visible - elif key == 'm' and self.music_exists: + elif key == "m" and self.music_exists: if self.music_paused: self.world.resume_music() self.music_paused = False else: self.world.pause_music() self.music_paused = True - elif key == 'c': + elif key == "c": if not self.app.fb.disable_crt: self.app.fb.toggle_crt() - elif key == '=' or key == '+': + elif key == "=" or key == "+": self.target_particles += 10 - self.art.write_string(0, 0, 0, 0, 'Embers: %s' % self.target_particles, 15, 1) - elif key == '-': + self.art.write_string( + 0, 0, 0, 0, "Embers: %s" % self.target_particles, 15, 1 + ) + elif key == "-": if self.target_particles <= 10: return self.target_particles -= 10 - self.art.write_string(0, 0, 0, 0, 'Embers: %s' % self.target_particles, 15, 1) + self.art.write_string( + 0, 0, 0, 0, "Embers: %s" % self.target_particles, 15, 1 + ) class FireParticle: - "Simulated particle, spawned and ticked and rendered by a Fireplace object." - + def __init__(self, fp): # pick char and color here; Fireplace should just run sim self.y = fp.art.height # spawn at random point along bottom edge, within margin - self.x = randint(SPAWN_MARGIN_X, fp.art.width - SPAWN_MARGIN_X) + self.x = randint(SPAWN_MARGIN_X, fp.art.width - SPAWN_MARGIN_X) # char here is not character index but density, which decays; # fp.weighted_chars is used to look up actual index self.char = randint(100, fp.art.charset.last_index - 1) @@ -214,7 +220,7 @@ class FireParticle: self.bg = randint(0, len(fp.art.palette.colors) - 1) # hang on to fireplace self.fp = fp - + def update(self): # no need for out-of-range checks; fireplace will cull particles that # reach the top of the screen @@ -228,9 +234,9 @@ class FireParticle: self.bg -= randint(0, 1) # don't bother with range checks on colors; # if random embers "flare up" that's cool - #self.fg = max(0, self.fg) - #self.bg = max(0, self.bg) - + # self.fg = max(0, self.fg) + # self.bg = max(0, self.bg) + def merge(self, other): # merge (sum w/ other) colors & chars (ie when particles overlap) self.char += other.char @@ -239,27 +245,26 @@ class FireParticle: class HelpScreen(GameObject): - art_src = 'help' + art_src = "help" alpha = 0.7 class CreditScreen(GameObject): - "Separate object for the clickable area of the help screen." - - art_src = 'credit' + + art_src = "credit" alpha = 0.7 handle_mouse_events = True - + def clicked(self, button, mouse_x, mouse_y): if self.visible: webbrowser.open(MUSIC_URL) - + def hovered(self, mouse_x, mouse_y): # hilight text on hover for frame, layer, x, y in TileIter(self.art): self.art.set_color_at(frame, layer, x, y, 2) - + def unhovered(self, mouse_x, mouse_y): for frame, layer, x, y in TileIter(self.art): self.art.set_color_at(frame, layer, x, y, 16) diff --git a/games/flood/scripts/game.py b/games/flood/scripts/game.py index 6d221c3..08f5591 100644 --- a/games/flood/scripts/game.py +++ b/games/flood/scripts/game.py @@ -1,4 +1,3 @@ - from random import choice from art import TileIter @@ -21,14 +20,14 @@ GS_LOST = 2 class Board(GameObject): generate_art = True art_width, art_height = BOARD_WIDTH, BOARD_HEIGHT - art_charset = 'jpetscii' - art_palette = 'c64_original' + art_charset = "jpetscii" + art_palette = "c64_original" handle_key_events = True - + def __init__(self, world, obj_data): GameObject.__init__(self, world, obj_data) self.reset() - + def reset(self): for frame, layer, x, y in TileIter(self.art): color = choice(TILE_COLORS) @@ -39,33 +38,33 @@ class Board(GameObject): self.flood_with_color(start_color) self.turns = STARTING_TURNS self.game_state = GS_PLAYING - + def get_adjacent_tiles(self, x, y): tiles = [] if x > 0: - tiles.append((x-1, y)) + tiles.append((x - 1, y)) if x < BOARD_WIDTH - 1: - tiles.append((x+1, y)) + tiles.append((x + 1, y)) if y > 0: - tiles.append((x, y-1)) + tiles.append((x, y - 1)) if y < BOARD_HEIGHT - 1: - tiles.append((x, y+1)) + tiles.append((x, y + 1)) return tiles - + def flood_with_color(self, flood_color): # set captured tiles to new color - for tile_x,tile_y in self.captured_tiles: + for tile_x, tile_y in self.captured_tiles: self.art.set_color_at(0, 0, tile_x, tile_y, flood_color, False) # capture like-colored tiles adjacent to captured tiles for frame, layer, x, y in TileIter(self.art): - if not (x, y) in self.captured_tiles: + if (x, y) not in self.captured_tiles: continue adjacents = self.get_adjacent_tiles(x, y) - for adj_x,adj_y in adjacents: + for adj_x, adj_y in adjacents: adj_color = self.art.get_bg_color_index_at(frame, layer, adj_x, adj_y) if adj_color == flood_color: self.captured_tiles.append((adj_x, adj_y)) - + def color_picked(self, color): self.flood_with_color(TILE_COLORS[color]) self.turns -= 1 @@ -74,14 +73,14 @@ class Board(GameObject): elif self.turns == 0: self.game_state = GS_LOST # TODO: reset after delay / feedback? - + def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): if self.game_state != GS_PLAYING: self.reset() return # get list of valid keys from length of tile_colors - valid_keys = ['%s' % str(i + 1) for i in range(len(TILE_COLORS))] - if not key in valid_keys: + valid_keys = ["%s" % str(i + 1) for i in range(len(TILE_COLORS))] + if key not in valid_keys: return key = int(key) - 1 self.color_picked(key) @@ -90,9 +89,9 @@ class Board(GameObject): class ColorBar(GameObject): generate_art = True art_width, art_height = len(TILE_COLORS), 1 - art_charset = 'jpetscii' - art_palette = 'c64_original' - + art_charset = "jpetscii" + art_palette = "c64_original" + def __init__(self, world, obj_data): GameObject.__init__(self, world, obj_data) i = 0 @@ -103,31 +102,31 @@ class ColorBar(GameObject): class TurnsBar(GameObject): - text = 'turns: %s' + text = "turns: %s" generate_art = True art_width, art_height = len(text) + 3, 1 - art_charset = 'jpetscii' - art_palette = 'c64_original' - + art_charset = "jpetscii" + art_palette = "c64_original" + def __init__(self, world, obj_data): GameObject.__init__(self, world, obj_data) self.board = None - + def pre_first_update(self): - self.board = self.world.get_all_objects_of_type('Board')[0] - + self.board = self.world.get_all_objects_of_type("Board")[0] + def draw_text(self): if not self.board: return self.art.clear_frame_layer(0, 0) new_text = self.text % self.board.turns if self.board.game_state == GS_WON: - new_text = 'won!!' + new_text = "won!!" elif self.board.game_state == GS_LOST: - new_text = 'lost :(' + new_text = "lost :(" color = TILE_COLORS[self.board.turns % len(TILE_COLORS)] self.art.write_string(0, 0, 0, 0, new_text, color, 0) - + def update(self): GameObject.update(self) self.draw_text() diff --git a/games/maze/scripts/hud.py b/games/maze/scripts/hud.py index bebc160..ffcd0ea 100644 --- a/games/maze/scripts/hud.py +++ b/games/maze/scripts/hud.py @@ -1,14 +1,14 @@ - from game_hud import GameHUD, GameHUDRenderable -class MazeHUD(GameHUD): +class MazeHUD(GameHUD): message_color = 4 - + def __init__(self, world): GameHUD.__init__(self, world) - self.msg_art = self.world.app.new_art('mazehud_msg', 42, 1, - 'jpetscii', 'c64_original') + self.msg_art = self.world.app.new_art( + "mazehud_msg", 42, 1, "jpetscii", "c64_original" + ) self.msg = GameHUDRenderable(self.world.app, self.msg_art) self.arts = [self.msg_art] self.renderables = [self.msg] @@ -17,10 +17,10 @@ class MazeHUD(GameHUD): aspect = self.world.app.window_height / self.world.app.window_width self.msg.scale_x = 0.075 * aspect self.msg.scale_y = 0.05 - self.current_msg = '' + self.current_msg = "" self.msg_art.clear_frame_layer(0, 0, 0, self.message_color) - self.post_msg('Welcome to MAZE, the amazing example game!') - + self.post_msg("Welcome to MAZE, the amazing example game!") + def post_msg(self, msg_text): self.current_msg = msg_text self.msg_art.clear_frame_layer(0, 0, 0, self.message_color) diff --git a/games/maze/scripts/objects.py b/games/maze/scripts/objects.py index 4aa610b..7094b57 100644 --- a/games/maze/scripts/objects.py +++ b/games/maze/scripts/objects.py @@ -1,27 +1,35 @@ - -import math, random +import math +import random from art import TileIter +from collision import ( + CST_CIRCLE, + CST_TILE, + CT_GENERIC_DYNAMIC, + CT_GENERIC_STATIC, + CT_NONE, +) from game_object import GameObject from game_util_objects import Player, StaticTileBG -from collision import CST_NONE, CST_CIRCLE, CST_AABB, CST_TILE, CT_NONE, CT_GENERIC_STATIC, CT_GENERIC_DYNAMIC, CT_PLAYER, CTG_STATIC, CTG_DYNAMIC + class MazeBG(StaticTileBG): z = -0.1 + class MazeNPC(GameObject): - art_src = 'npc' + art_src = "npc" use_art_instance = True col_radius = 0.5 collision_shape_type = CST_CIRCLE collision_type = CT_GENERIC_STATIC - bark = 'Well hello there!' - + bark = "Well hello there!" + def started_colliding(self, other): if not isinstance(other, Player): return self.world.hud.post_msg(self.bark) - + def pre_first_update(self): self.z = 0.1 # TODO: investigate why this random color set doesn't seem to work @@ -30,18 +38,19 @@ class MazeNPC(GameObject): for art in self.arts.values(): art.set_all_non_transparent_colors(random_color) + class MazeBaker(MazeNPC): - bark = 'Sorry, all outta bread today!' + bark = "Sorry, all outta bread today!" + class MazeCritter(MazeNPC): - "dynamically-spawned NPC that wobbles around" - + collision_type = CT_GENERIC_DYNAMIC should_save = False move_rate = 0.25 - bark = 'wheee!' - + bark = "wheee!" + def update(self): # skitter around randomly x, y = (random.random() * 2) - 1, (random.random() * 2) - 1 @@ -52,50 +61,49 @@ class MazeCritter(MazeNPC): class MazePickup(GameObject): - collision_shape_type = CST_CIRCLE collision_type = CT_GENERIC_DYNAMIC col_radius = 0.5 - + hold_offset_y = 1.2 consume_on_use = True - sound_filenames = {'pickup': 'pickup.ogg'} - + sound_filenames = {"pickup": "pickup.ogg"} + def __init__(self, world, obj_data=None): GameObject.__init__(self, world, obj_data) self.holder = None - + def started_colliding(self, other): if not isinstance(other, Player): return if self is other.held_object: return other.pick_up(self) - + def stopped_colliding(self, other): if not isinstance(other, Player): return if self is not other.held_object: self.enable_collision() - + def picked_up(self, new_holder): self.holder = new_holder - self.world.hud.post_msg('got %s!' % self.display_name) + self.world.hud.post_msg("got %s!" % self.display_name) self.disable_collision() - self.play_sound('pickup') - + self.play_sound("pickup") + def used(self, user): - if 'used' in self.sound_filenames: - self.play_sound('used') + if "used" in self.sound_filenames: + self.play_sound("used") if self.consume_on_use: self.destroy() - + def destroy(self): if self.holder: self.holder.held_object = None self.holder = None GameObject.destroy(self) - + def update(self): GameObject.update(self) if not self.holder: @@ -103,33 +111,33 @@ class MazePickup(GameObject): # if held, shadow holder self.x = self.holder.x # bob slightly above holder's head - bob_y = math.sin(self.world.get_elapsed_time() / 100) / 10 + bob_y = math.sin(self.world.get_elapsed_time() / 100) / 10 self.y = self.holder.y + self.hold_offset_y + bob_y self.z = self.holder.z class MazeKey(MazePickup): - art_src = 'key' - display_name = 'a gold key' - used_message = 'unlocked!' + art_src = "key" + display_name = "a gold key" + used_message = "unlocked!" + class MazeAx(MazePickup): - art_src = 'ax' - display_name = 'an ax' + art_src = "ax" + display_name = "an ax" consume_on_use = False - used_message = 'chop!' + used_message = "chop!" # TODO: see if there's a way to add to MazePickup's sound dict here :/ - sound_filenames = {'pickup': 'pickup.ogg', - 'used': 'break.ogg'} + sound_filenames = {"pickup": "pickup.ogg", "used": "break.ogg"} + class MazePortalKey(MazePickup): - art_src = 'artifact' - display_name = 'the Artifact of Zendor' - used_message = '!!??!?!!?!?!?!!' + art_src = "artifact" + display_name = "the Artifact of Zendor" + used_message = "!!??!?!!?!?!?!!" consume_on_use = False - sound_filenames = {'pickup': 'artifact.ogg', - 'used': 'portal.ogg'} - + sound_filenames = {"pickup": "artifact.ogg", "used": "portal.ogg"} + def update(self): MazePickup.update(self) ch, fg, bg, xform = self.art.get_tile_at(0, 0, 0, 0) @@ -146,21 +154,21 @@ class MazePortalKey(MazePickup): class MazeLock(StaticTileBG): - art_src = 'lock' + art_src = "lock" collision_shape_type = CST_CIRCLE collision_type = CT_GENERIC_DYNAMIC col_radius = 0.5 mass = 0.0 key_type = MazeKey - + def started_colliding(self, other): if not isinstance(other, Player): return if other.held_object and type(other.held_object) is self.key_type: self.unlocked(other) else: - self.world.hud.post_msg('blocked - need %s!' % self.key_type.display_name) - + self.world.hud.post_msg("blocked - need %s!" % self.key_type.display_name) + def unlocked(self, other): self.disable_collision() self.visible = False @@ -168,22 +176,21 @@ class MazeLock(StaticTileBG): class MazeBlockage(MazeLock): - art_src = 'debris' + art_src = "debris" key_type = MazeAx class MazePortalGate(MazeLock): - - art_src = 'portalgate' + art_src = "portalgate" key_type = MazePortalKey collision_shape_type = CST_TILE collision_type = CT_GENERIC_STATIC - + def update(self): MazeLock.update(self) if self.collision_type == CT_NONE: - if not self.art.is_script_running('evap'): - self.art.run_script_every('evap') + if not self.art.is_script_running("evap"): + self.art.run_script_every("evap") return # cycle non-black colors BLACK = 1 @@ -207,7 +214,8 @@ class MazePortalGate(MazeLock): class MazePortal(GameObject): - art_src = 'portal' + art_src = "portal" + def update(self): GameObject.update(self) if self.app.updates % 2 != 0: @@ -215,17 +223,17 @@ class MazePortal(GameObject): ramps = {11: 10, 10: 3, 3: 11} for frame, layer, x, y in TileIter(self.art): ch, fg, bg, xform = self.art.get_tile_at(frame, layer, x, y) - fg = ramps.get(fg, None) + fg = ramps.get(fg) self.art.set_tile_at(frame, layer, x, y, ch, fg, bg, xform) class MazeStandingNPC(GameObject): - art_src = 'npc' + art_src = "npc" col_radius = 0.5 collision_shape_type = CST_CIRCLE collision_type = CT_GENERIC_DYNAMIC - bark = 'Well hello there!' - + bark = "Well hello there!" + def started_colliding(self, other): if not isinstance(other, Player): return diff --git a/games/maze/scripts/player.py b/games/maze/scripts/player.py index 3df525d..52e4250 100644 --- a/games/maze/scripts/player.py +++ b/games/maze/scripts/player.py @@ -1,14 +1,15 @@ - import math -from game_util_objects import Player, BlobShadow +from game_util_objects import BlobShadow, Player from games.maze.scripts.rooms import OutsideRoom + class PlayerBlobShadow(BlobShadow): z = 0 fixed_z = True scale_x = scale_y = 0.5 offset_y = -0.5 + def pre_first_update(self): BlobShadow.pre_first_update(self) # TODO: figure out why class default scale isn't taking? @@ -16,24 +17,24 @@ class PlayerBlobShadow(BlobShadow): class MazePlayer(Player): - art_src = 'player' - move_state = 'stand' + art_src = "player" + move_state = "stand" col_radius = 0.5 # TODO: setting this to 2 fixes tunneling, but shouldn't slow down the player! fast_move_steps = 2 - attachment_classes = { 'shadow': 'PlayerBlobShadow' } - + attachment_classes = {"shadow": "PlayerBlobShadow"} + def __init__(self, world, obj_data=None): Player.__init__(self, world, obj_data) self.held_object = None - + def pick_up(self, pickup): # drop any other held item first if self.held_object: self.drop(self.held_object, pickup) self.held_object = pickup pickup.picked_up(self) - + def drop(self, pickup, new_pickup=None): # drop pickup in place of one we're swapping with, else drop at feet if new_pickup: @@ -41,11 +42,11 @@ class MazePlayer(Player): else: pickup.x, pickup.y = self.x, self.y pickup.holder = None - + def use_item(self): self.world.hud.post_msg(self.held_object.used_message) self.held_object.used(self) - + def update(self): Player.update(self) if type(self.world.current_room) is OutsideRoom: diff --git a/games/maze/scripts/rooms.py b/games/maze/scripts/rooms.py index 6c66fe0..d8e5652 100644 --- a/games/maze/scripts/rooms.py +++ b/games/maze/scripts/rooms.py @@ -1,25 +1,22 @@ - from game_room import GameRoom class MazeRoom(GameRoom): - def exited(self, new_room): GameRoom.exited(self, new_room) # clear message line when exiting if self.world.hud: - self.world.hud.post_msg('') + self.world.hud.post_msg("") class OutsideRoom(MazeRoom): - camera_follow_player = True - + def entered(self, old_room): MazeRoom.entered(self, old_room) self.world.collision_enabled = False self.world.app.camera.y_tilt = 4 - + def exited(self, new_room): MazeRoom.exited(self, new_room) self.world.collision_enabled = True diff --git a/games/platso/scripts/platso.py b/games/platso/scripts/platso.py index 50a300e..ac65016 100644 --- a/games/platso/scripts/platso.py +++ b/games/platso/scripts/platso.py @@ -1,20 +1,19 @@ +import math +import random -import math, random +from game_util_objects import Character, Player, StaticTileBG, WarpTrigger -from game_object import GameObject -from game_util_objects import StaticTileBG, Player, Character, WarpTrigger -from collision import CST_AABB class PlatformWorld(StaticTileBG): draw_col_layer = True + class PlatformPlayer(Player): - # from http://www.piratehearts.com/blog/2010/08/30/40/: # JumpSpeed = sqrt(2.0f * Gravity * JumpHeight); - - art_src = 'player' - #collision_shape_type = CST_AABB + + art_src = "player" + # collision_shape_type = CST_AABB col_width = 2 col_height = 3 handle_key_events = True @@ -25,16 +24,16 @@ class PlatformPlayer(Player): ground_friction = 20 air_friction = 15 max_jump_press_time = 0.15 - editable = Player.editable + ['max_jump_press_time'] - jump_key = 'x' - + editable = Player.editable + ["max_jump_press_time"] + jump_key = "x" + def __init__(self, world, obj_data=None): Player.__init__(self, world, obj_data) self.jump_time = 0 # don't jump again until jump is released and pressed again self.jump_ready = True self.started_jump = False - + def started_colliding(self, other): Player.started_colliding(self, other) if isinstance(other, PlatformMonster): @@ -42,77 +41,87 @@ class PlatformPlayer(Player): dx, dy = other.x - self.x, other.y - self.y if abs(dy) > abs(dx) and dy < -1: other.destroy() - + def is_affected_by_gravity(self): return True - + def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): if key == self.jump_key and self.jump_ready: self.jump() self.jump_ready = False self.started_jump = True - + def handle_key_up(self, key, shift_pressed, alt_pressed, ctrl_pressed): if key == self.jump_key: self.jump_ready = True - + def allow_move_y(self, dy): # disable regular up/down movement, jump button sets move_y directly return False - + def update_state(self): - self.state = 'stand' if self.is_on_ground() and (self.move_x, self.move_y) == (0, 0) else 'walk' - + self.state = ( + "stand" + if self.is_on_ground() and (self.move_x, self.move_y) == (0, 0) + else "walk" + ) + def moved_this_frame(self): - delta = math.sqrt(abs(self.last_x - self.x) ** 2 + abs(self.last_y - self.y) ** 2 + abs(self.last_z - self.z) ** 2) + delta = math.sqrt( + abs(self.last_x - self.x) ** 2 + + abs(self.last_y - self.y) ** 2 + + abs(self.last_z - self.z) ** 2 + ) return delta > self.stop_velocity - + def is_on_ground(self): # works for now: just check for -Y contact with first world object - ground = self.world.get_first_object_of_type('PlatformWorld') + ground = self.world.get_first_object_of_type("PlatformWorld") contact = self.collision.contacts.get(ground.name, None) if not contact: return False return contact.overlap.y < 0 - + def jump(self): self.jump_time += self.get_time_since_last_update() / 1000 if self.jump_time < self.max_jump_press_time: self.move_y += 1 - + def update(self): on_ground = self.is_on_ground() if on_ground and self.jump_time > 0: self.jump_time = 0 # poll jump key for variable length jump - if self.world.app.il.is_key_pressed(self.jump_key) and \ - (self.started_jump or not on_ground): + if self.world.app.il.is_key_pressed(self.jump_key) and ( + self.started_jump or not on_ground + ): self.jump() self.started_jump = False Player.update(self) # wobble as we walk a la ELC2 - if self.state == 'walk' and on_ground: + if self.state == "walk" and on_ground: self.y += math.sin(self.world.app.updates) / 5 + class PlatformMonster(Character): - art_src = 'monster' - move_state = 'stand' + art_src = "monster" + move_state = "stand" animating = True fast_move_steps = 2 move_accel_x = 100 col_radius = 1 - + def pre_first_update(self): # pick random starting direction self.move_dir_x = random.choice([-1, 1]) - self.set_timer_function('hit_wall', self.check_wall_hits, 0.2) - + self.set_timer_function("hit_wall", self.check_wall_hits, 0.2) + def is_affected_by_gravity(self): return True - + def allow_move_y(self, dy): return False - + def check_wall_hits(self): "Turn around if a wall is immediately ahead of direction we're moving." # check collision in direction we're moving @@ -123,20 +132,22 @@ class PlatformMonster(Character): x = self.x - self.col_radius - margin y = self.y # DEBUG see trace destination - #lines = [(self.x, self.y, 0), (x, y, 0)] - #self.app.debug_line_renderable.set_lines(lines) - hits, shapes = self.world.get_colliders_at_point(x, y, - #include_object_names=[], - include_class_names=['PlatformWorld', - 'PlatformMonster'], - exclude_object_names=[self.name]) + # lines = [(self.x, self.y, 0), (x, y, 0)] + # self.app.debug_line_renderable.set_lines(lines) + hits, shapes = self.world.get_colliders_at_point( + x, + y, + # include_object_names=[], + include_class_names=["PlatformWorld", "PlatformMonster"], + exclude_object_names=[self.name], + ) if len(hits) > 0: self.move_dir_x = -self.move_dir_x - + def update(self): self.move(self.move_dir_x, 0) Character.update(self) class PlatformWarpTrigger(WarpTrigger): - warp_class_names = ['Player', 'PlatformMonster'] + warp_class_names = ["Player", "PlatformMonster"] diff --git a/games/shmup/scripts/shmup.py b/games/shmup/scripts/shmup.py index e04bab0..788cc26 100644 --- a/games/shmup/scripts/shmup.py +++ b/games/shmup/scripts/shmup.py @@ -1,20 +1,21 @@ - -import math, random +import math +import random from game_object import GameObject -from game_util_objects import Player, Character, Projectile, StaticTileBG, ObjectSpawner +from game_util_objects import Character, ObjectSpawner, Player, Projectile, StaticTileBG + class ShmupPlayer(Player): state_changes_art = False - move_state = 'stand' - art_src = 'player' + move_state = "stand" + art_src = "player" handle_key_events = True - invincible = False # DEBUG - serialized = Player.serialized + ['invincible'] + invincible = False # DEBUG + serialized = Player.serialized + ["invincible"] respawn_delay = 3 # refire delay, else holding X chokes game fire_delay = 0.15 - + def __init__(self, world, obj_data=None): Player.__init__(self, world, obj_data) # track last death and last fire time for respawn and refire delays @@ -22,33 +23,33 @@ class ShmupPlayer(Player): self.last_fire_time = 0 # save our start position self.start_x, self.start_y = self.x, self.y - + def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): - if key == 'x' and self.state == 'dead': + if key == "x" and self.state == "dead": # respawn after short delay time = self.world.get_elapsed_time() / 1000 if time >= self.last_death_time + self.respawn_delay: - self.state = 'stand' + self.state = "stand" self.set_loc(self.start_x, self.start_y) self.visible = True - + def update_state(self): # only two states, ignore stuff parent class does for this pass - + def die(self, killer): - if self.invincible or self.state == 'dead': + if self.invincible or self.state == "dead": return boom = Boom(self.world) boom.set_loc(self.x, self.y) - self.state = 'dead' + self.state = "dead" self.last_death_time = self.world.get_elapsed_time() / 1000 self.visible = False - + def update(self): Player.update(self) # poll fire key directly for continuous fire (with refire delay) - if self.state != 'dead' and self.world.app.il.is_key_pressed('x'): + if self.state != "dead" and self.world.app.il.is_key_pressed("x"): time = self.world.get_elapsed_time() / 1000 if time >= self.last_fire_time + self.fire_delay: proj = ShmupPlayerProjectile(self.world) @@ -58,38 +59,44 @@ class ShmupPlayer(Player): class PlayerBlocker(StaticTileBG): "keeps player from advancing too far upfield" - art_src = 'blockline_horiz' - noncolliding_classes = ['Projectile', 'ShmupEnemy'] + + art_src = "blockline_horiz" + noncolliding_classes = ["Projectile", "ShmupEnemy"] + class EnemySpawner(ObjectSpawner): "sits at top of screen and spawns enemies" - art_src = 'spawn_area' + + art_src = "spawn_area" spawn_random_in_bounds = True trigger_on_room_enter = False - + def __init__(self, world, obj_data=None): ObjectSpawner.__init__(self, world, obj_data) self.next_spawn_time = 0 self.target_enemy_count = 1 - + def can_spawn(self): - player = self.world.get_first_object_of_type('ShmupPlayer') + player = self.world.get_first_object_of_type("ShmupPlayer") # only spawn if player has fired, there's room, and it's time - return player and player.state != 'dead' and \ - player.last_fire_time > 0 and \ - len(self.spawned_objects) < self.target_enemy_count and \ - self.world.get_elapsed_time() >= self.next_spawn_time - + return ( + player + and player.state != "dead" + and player.last_fire_time > 0 + and len(self.spawned_objects) < self.target_enemy_count + and self.world.get_elapsed_time() >= self.next_spawn_time + ) + def get_spawn_class_name(self): roll = random.random() # pick random enemy type to spawn if roll > 0.8: - return 'Enemy1' + return "Enemy1" elif roll > 0.6: - return 'Enemy2' + return "Enemy2" else: - return 'Asteroid' - + return "Asteroid" + def update(self): StaticTileBG.update(self) # bump up enemy counts as time goes on @@ -106,37 +113,42 @@ class EnemySpawner(ObjectSpawner): next_delay = random.random() * 3 self.next_spawn_time = self.world.get_elapsed_time() + next_delay * 1000 + class EnemyDeleter(StaticTileBG): "deletes enemies once they hit a certain point on screen" - art_src = 'blockline_horiz' + + art_src = "blockline_horiz" + def started_colliding(self, other): if isinstance(other, ShmupEnemy): other.destroy() + class ShmupEnemy(Character): state_changes_art = False - move_state = 'stand' + move_state = "stand" should_save = False - invincible = False # DEBUG - serialized = Character.serialized + ['invincible'] - + invincible = False # DEBUG + serialized = Character.serialized + ["invincible"] + def started_colliding(self, other): if isinstance(other, ShmupPlayer): other.die(self) - + def fire_proj(self): proj = ShmupEnemyProjectile(self.world) # fire downward proj.fire(self, 0, -1) - + def update(self): self.move(0, -1) Character.update(self) + class Enemy1(ShmupEnemy): - art_src = 'enemy1' + art_src = "enemy1" move_accel_y = 100 - + def update(self): # sine wave motion in X time = self.world.get_elapsed_time() @@ -146,16 +158,17 @@ class Enemy1(ShmupEnemy): self.fire_proj() ShmupEnemy.update(self) + class Enemy2(ShmupEnemy): - art_src = 'enemy2' + art_src = "enemy2" animating = True move_accel_y = 50 - + def pre_first_update(self): ShmupEnemy.pre_first_update(self) # pick random lateral movement goal self.goal_x, y = self.spawner.get_spawn_location() - + def update(self): # move to random goal X dx = self.goal_x - self.x @@ -171,16 +184,20 @@ class Enemy2(ShmupEnemy): self.fire_proj() ShmupEnemy.update(self) + class Asteroid(ShmupEnemy): "totally inert, just moves slowly down the screen" - art_src = 'asteroid' + + art_src = "asteroid" move_accel_y = 200 + class ShmupPlayerProjectile(Projectile): animating = True - art_src = 'player_proj' + art_src = "player_proj" use_art_instance = True - noncolliding_classes = Projectile.noncolliding_classes + ['Boom', 'Player'] + noncolliding_classes = Projectile.noncolliding_classes + ["Boom", "Player"] + def started_colliding(self, other): if isinstance(other, ShmupEnemy) and not other.invincible: boom = Boom(self.world) @@ -189,48 +206,52 @@ class ShmupPlayerProjectile(Projectile): other.destroy() self.destroy() + class ShmupEnemyProjectile(Projectile): animating = True - art_src = 'enemy_proj' + art_src = "enemy_proj" use_art_instance = True - noncolliding_classes = Projectile.noncolliding_classes + ['Boom', 'ShmupEnemy'] + noncolliding_classes = Projectile.noncolliding_classes + ["Boom", "ShmupEnemy"] + def started_colliding(self, other): - if isinstance(other, ShmupPlayer) and other.state != 'dead': + if isinstance(other, ShmupPlayer) and other.state != "dead": other.die(self) self.destroy() + class Boom(GameObject): - art_src = 'boom' + art_src = "boom" animating = True use_art_instance = True should_save = False z = 0.5 scale_x, scale_y = 3, 3 lifespan = 0.5 + def get_acceleration(self, vel_x, vel_y, vel_z): return 0, 0, -100 + class Starfield(GameObject): - "scrolling background with stars generated on-the-fly - no PSCI file!" - + generate_art = True art_width, art_height = 30, 41 - art_charset = 'jpetscii' - alpha = 0.25 # NOTE: this will be overriden by saved instance because it's in the list of serialized properties + art_charset = "jpetscii" + alpha = 0.25 # NOTE: this will be overriden by saved instance because it's in the list of serialized properties # indices of star characters star_chars = [201] - + def pre_first_update(self): self.art.clear_frame_layer(0, 0) - + def create_star(self): "create a star at a random point along the top edge" x = int(random.random() * self.art_width) char = random.choice(self.star_chars) color = self.art.palette.get_random_color_index() self.art.set_tile_at(0, 0, x, 0, char, color) - + def update(self): # maybe create a star at the top, clear bottom line, then shift-wrap if random.random() < 0.25: diff --git a/games/wildflowers/scripts/flower.py b/games/wildflowers/scripts/flower.py index a7af5a0..84f3e48 100644 --- a/games/wildflowers/scripts/flower.py +++ b/games/wildflowers/scripts/flower.py @@ -1,14 +1,12 @@ +import random +import time -import time, random - +from art import ART_DIR, UV_FLIPX, UV_FLIPY, UV_ROTATE180 from game_object import GameObject -from art import UV_FLIPX, UV_FLIPY, UV_ROTATE180, ART_DIR -from renderable import TileRenderable - -from games.wildflowers.scripts.ramps import PALETTE_RAMPS -from games.wildflowers.scripts.petal import Petal from games.wildflowers.scripts.frond import Frond - +from games.wildflowers.scripts.petal import Petal +from games.wildflowers.scripts.ramps import PALETTE_RAMPS +from renderable import TileRenderable # TODO: random size range? # (should also change camera zoom, probably frond/petal counts) @@ -16,19 +14,18 @@ FLOWER_WIDTH, FLOWER_HEIGHT = 16, 16 class FlowerObject(GameObject): - generate_art = True should_save = False physics_move = False art_width, art_height = FLOWER_WIDTH, FLOWER_HEIGHT - + min_petals, max_petals = 0, 4 min_fronds, max_fronds = 0, 8 # every flower must have at least this many petals + fronds minimum_complexity = 4 # app updates per grow update; 1 = grow every frame ticks_per_grow = 4 - + # DEBUG: if True, add current time to date seed as a decimal, # to test with highly specific values # (note: this turns the seed from an int into a float) @@ -36,13 +33,13 @@ class FlowerObject(GameObject): # DEBUG: if nonzero, use this seed for testing debug_seed = 0 debug_log = False - + def __init__(self, world, obj_data=None): GameObject.__init__(self, world, obj_data) # set random seed based on date, a different flower each day t = time.localtime() year, month, day = t.tm_year, t.tm_mon, t.tm_mday - weekday = t.tm_wday # 0 = monday + weekday = t.tm_wday # 0 = monday date = year * 10000 + month * 100 + day if self.seed_includes_time: date += t.tm_hour * 0.01 + t.tm_min * 0.0001 + t.tm_sec * 0.000001 @@ -54,21 +51,23 @@ class FlowerObject(GameObject): # pick a random dark BG color (will be quantized to palette) r, g, b = random.random() / 10, random.random() / 10, random.random() / 10 # set up art with character set, size, and a random (supported) palette - self.art.set_charset_by_name('jpetscii') + self.art.set_charset_by_name("jpetscii") palette = random.choice(list(PALETTE_RAMPS.keys())) self.art.set_palette_by_name(palette) # quantize bg color and set it for art and world - self.bg_index = self.art.palette.get_closest_color_index(int(r * 255), int(g * 255), int(b * 255)) + self.bg_index = self.art.palette.get_closest_color_index( + int(r * 255), int(g * 255), int(b * 255) + ) bg_color = self.art.palette.colors[self.bg_index] self.world.bg_color[0] = bg_color[0] / 255.0 self.world.bg_color[1] = bg_color[1] / 255.0 self.world.bg_color[2] = bg_color[2] / 255.0 - self.world.bg_color[3] = 1.0 # set here or alpha is zero? + self.world.bg_color[3] = 1.0 # set here or alpha is zero? self.art.resize(self.art_width, self.art_height) - self.app.ui.adjust_for_art_resize(self) # grid etc + self.app.ui.adjust_for_art_resize(self) # grid etc self.art.clear_frame_layer(0, 0, bg_color=self.bg_index) # petals on a layer underneath fronds? - #self.art.add_layer(z=-0.001, name='petals') + # self.art.add_layer(z=-0.001, name='petals') self.finished_growing = False # some flowers can be more petal-centric or frond-centric, # but keep a certain minimum complexity @@ -78,29 +77,36 @@ class FlowerObject(GameObject): petal_count = random.randint(self.min_petals, self.max_petals) frond_count = random.randint(self.min_fronds, self.max_fronds) self.petals = [] - #petal_count = 5 # DEBUG + # petal_count = 5 # DEBUG for i in range(petal_count): self.petals.append(Petal(self, i)) # sort petals by radius largest to smallest, # so big ones don't totally stomp smaller ones self.petals.sort(key=lambda item: item.goal_radius, reverse=True) self.fronds = [] - #frond_count = 0 # DEBUG + # frond_count = 0 # DEBUG for i in range(frond_count): self.fronds.append(Frond(self, i)) # track # of growth updates we've had self.grows = 0 # create an art document we can add frames to and later export - self.export_filename = '%s%swildflower_%s' % (self.app.documents_dir, ART_DIR, self.seed) - self.exportable_art = self.app.new_art(self.export_filename, - self.art_width, self.art_height, - self.art.charset.name, - self.art.palette.name) + self.export_filename = "%s%swildflower_%s" % ( + self.app.documents_dir, + ART_DIR, + self.seed, + ) + self.exportable_art = self.app.new_art( + self.export_filename, + self.art_width, + self.art_height, + self.art.charset.name, + self.art.palette.name, + ) # re-set art's filename to be in documents dir rather than game dir :/ self.exportable_art.set_filename(self.export_filename) # image export process needs a renderable r = TileRenderable(self.app, self.exportable_art) - + def update(self): GameObject.update(self) # grow only every few ticks, so you can watch the design grow @@ -108,10 +114,10 @@ class FlowerObject(GameObject): return if not self.finished_growing: self.update_growth() - + def update_growth(self): if self.debug_log: - print('update growth:') + print("update growth:") grew = False for p in self.petals: if not p.finished_growing: @@ -136,8 +142,8 @@ class FlowerObject(GameObject): self.finished_growing = True self.exportable_art.set_active_frame(self.exportable_art.frames - 1) if self.debug_log: - print('flower finished') - + print("flower finished") + def paint_mirrored(self, layer, x, y, char, fg, bg=None): # only paint if in top left quadrant if x > (self.art_width / 2) - 1 or y > (self.art_height / 2) - 1: @@ -148,13 +154,12 @@ class FlowerObject(GameObject): top_right = (self.art_width - 1 - x, y) bottom_left = (x, self.art_height - 1 - y) bottom_right = (self.art_width - 1 - x, self.art_height - 1 - y) - self.art.set_tile_at(0, layer, *top_right, - char, fg, bg, transform=UV_FLIPX) - self.art.set_tile_at(0, layer, *bottom_left, - char, fg, bg, transform=UV_FLIPY) - self.art.set_tile_at(0, layer, *bottom_right, - char, fg, bg, transform=UV_ROTATE180) - + self.art.set_tile_at(0, layer, *top_right, char, fg, bg, transform=UV_FLIPX) + self.art.set_tile_at(0, layer, *bottom_left, char, fg, bg, transform=UV_FLIPY) + self.art.set_tile_at( + 0, layer, *bottom_right, char, fg, bg, transform=UV_ROTATE180 + ) + def copy_new_frame(self): # add new frame to art for export # (art starts with 1 frame, only do this after first frame written) diff --git a/games/wildflowers/scripts/frond.py b/games/wildflowers/scripts/frond.py index 29b2c1e..1e13b00 100644 --- a/games/wildflowers/scripts/frond.py +++ b/games/wildflowers/scripts/frond.py @@ -1,51 +1,57 @@ - import random from games.wildflowers.scripts.ramps import RampIterator - # growth direction consts -NONE = (0, 0) -LEFT = (-1, 0) -LEFT_UP = (-1, -1) -UP = (0, -1) -RIGHT_UP = (1, -1) -RIGHT = (1, 0) +NONE = (0, 0) +LEFT = (-1, 0) +LEFT_UP = (-1, -1) +UP = (0, -1) +RIGHT_UP = (1, -1) +RIGHT = (1, 0) RIGHT_DOWN = (1, 1) -DOWN = (0, 1) -LEFT_DOWN = (-1, 1) +DOWN = (0, 1) +LEFT_DOWN = (-1, 1) DIRS = [LEFT, LEFT_UP, UP, RIGHT_UP, RIGHT, RIGHT_DOWN, DOWN, LEFT_DOWN] FROND_CHARS = [ # thick and skinny \ - 151, 166, + 151, + 166, # thick and skinny / - 150, 167, + 150, + 167, # thick and skinny X - 183, 182, + 183, + 182, # solid inward wedges, NW NE SE SW - 148, 149, 164, 165 + 148, + 149, + 164, + 165, ] class Frond: - min_life, max_life = 3, 16 random_char_chance = 0.5 mutate_char_chance = 0.2 # layer all fronds should paint on layer = 0 debug = False - + def __init__(self, flower, index): self.flower = flower self.index = index self.finished_growing = False # choose growth function - self.growth_functions = [self.grow_straight_line, self.grow_curl, - self.grow_wander_outward] + self.growth_functions = [ + self.grow_straight_line, + self.grow_curl, + self.grow_wander_outward, + ] self.get_grow_dir = random.choice(self.growth_functions) - #self.get_grow_dir = self.grow_curl # DEBUG + # self.get_grow_dir = self.grow_curl # DEBUG # for straight line growers, set a consistent direction if self.get_grow_dir == self.grow_straight_line: self.grow_line = random.choice(DIRS) @@ -65,7 +71,7 @@ class Frond: else: self.char = random.randint(1, 255) # first grow() will paint first character - + def grow(self): """ grows this frond by another tile @@ -75,16 +81,22 @@ class Frond: if self.life <= 0 or self.color == self.ramp.end: self.finished_growing = True if self.debug: - print(' frond %i finished.' % self.index) + print(" frond %i finished." % self.index) return painted if self.debug: - print(' frond %i at (%i, %i) using %s' % (self.index, self.x, self.y, self.get_grow_dir.__name__)) + print( + " frond %i at (%i, %i) using %s" + % (self.index, self.x, self.y, self.get_grow_dir.__name__) + ) # if we're out of bounds, simply don't paint; # we might go back in bounds next grow - if 0 <= self.x < self.flower.art_width - 1 and \ - 0 <= self.y < self.flower.art_height - 1: - self.flower.paint_mirrored(self.layer, self.x, self.y, - self.char, self.color) + if ( + 0 <= self.x < self.flower.art_width - 1 + and 0 <= self.y < self.flower.art_height - 1 + ): + self.flower.paint_mirrored( + self.layer, self.x, self.y, self.char, self.color + ) painted = True self.growth_history.append((self.x, self.y)) self.life -= 1 @@ -104,16 +116,16 @@ class Frond: grow_x, grow_y = self.get_grow_dir((last_x, last_y)) self.x, self.y = self.x + grow_x, self.y + grow_y return painted - + # paint and growth functions work in top left quadrant, then mirrored - + def grow_straight_line(self, last_dir): return self.grow_line - + def grow_wander_outward(self, last_dir): # (original prototype growth algo) return random.choice([LEFT_UP, LEFT, UP]) - + def grow_curl(self, last_dir): if last_dir == NONE: return random.choice([LEFT, LEFT_UP, UP]) diff --git a/games/wildflowers/scripts/petal.py b/games/wildflowers/scripts/petal.py index 7a86b9d..30b0b18 100644 --- a/games/wildflowers/scripts/petal.py +++ b/games/wildflowers/scripts/petal.py @@ -1,33 +1,41 @@ - -import random, math +import math +import random from games.wildflowers.scripts.ramps import RampIterator - PETAL_CHARS = [ # solid block 255, # shaded boxes - 254, 253, + 254, + 253, # solid circle 122, # curved corner lines, NW NE SE SW - 105, 107, 139, 137, + 105, + 107, + 139, + 137, # mostly-solid curved corners, NW NE SE SW - 144, 146, 178, 176, + 144, + 146, + 178, + 176, # solid inward wedges, NW NE SE SW - 148, 149, 164, 165 + 148, + 149, + 164, + 165, ] class Petal: - min_radius = 3 mutate_char_chance = 0.2 # layer all petals should paint on layer = 0 debug = False - + def __init__(self, flower, index): self.flower = flower self.index = index @@ -36,8 +44,12 @@ class Petal: max_radius = int(self.flower.art_width / 2) self.goal_radius = random.randint(self.min_radius, max_radius) self.radius = 0 - ring_styles = [self.get_ring_tiles_box, self.get_ring_tiles_wings, - self.get_ring_tiles_diamond, self.get_ring_tiles_circle] + ring_styles = [ + self.get_ring_tiles_box, + self.get_ring_tiles_wings, + self.get_ring_tiles_diamond, + self.get_ring_tiles_circle, + ] self.get_ring_tiles = random.choice(ring_styles) # pick a starting point near center w, h = self.flower.art_width, self.flower.art_height @@ -48,14 +60,23 @@ class Petal: self.color = self.ramp.color # random char from predefined list self.char = random.choice(PETAL_CHARS) - + def grow(self): # grow outward (up and left) from center in "rings" if self.radius >= self.goal_radius: self.finished_growing = True return if self.debug: - print(' petal %i at (%i, %i) at radius %i using %s' % (self.index, self.x, self.y, self.radius, self.get_ring_tiles.__name__)) + print( + " petal %i at (%i, %i) at radius %i using %s" + % ( + self.index, + self.x, + self.y, + self.radius, + self.get_ring_tiles.__name__, + ) + ) self.paint_ring() # grow and change self.radius += 1 @@ -63,19 +84,20 @@ class Petal: # roll against chaos to mutate character if random.random() < self.chaos * self.mutate_char_chance: self.char = random.choice(PETAL_CHARS) - + def paint_ring(self): tiles = self.get_ring_tiles() for t in tiles: x = self.x - t[0] y = self.y - t[1] # don't paint out of bounds - if 0 <= x < self.flower.art_width - 1 and \ - 0 <= y < self.flower.art_height - 1: - self.flower.paint_mirrored(self.layer, x, y, - self.char, self.color) - #print('%s, %s' % (x, y)) - + if ( + 0 <= x < self.flower.art_width - 1 + and 0 <= y < self.flower.art_height - 1 + ): + self.flower.paint_mirrored(self.layer, x, y, self.char, self.color) + # print('%s, %s' % (x, y)) + def get_ring_tiles_box(self): tiles = [] for x in range(self.radius + 1): @@ -83,7 +105,7 @@ class Petal: for y in range(self.radius + 1): tiles.append((self.radius, y)) return tiles - + def get_ring_tiles_dealieX(self): # not sure what to call this but it's a nice shape tiles = [] @@ -91,7 +113,7 @@ class Petal: for x in range(self.radius): tiles.append((x - self.radius, y - self.radius)) return tiles - + def get_ring_tiles_wings(self): # not sure what to call this but it's a nice shape tiles = [] @@ -103,7 +125,6 @@ class Petal: tiles.append((x, y)) return tiles - def get_ring_tiles_diamond(self): tiles = [] for y in range(self.radius, -1, -1): @@ -111,7 +132,7 @@ class Petal: if x + y == self.radius: tiles.append((x, y)) return tiles - + def get_ring_tiles_circle(self): tiles = [] angle = 0 @@ -120,6 +141,6 @@ class Petal: angle += math.radians(90.0 / resolution) x = round(math.cos(angle) * self.radius) y = round(math.sin(angle) * self.radius) - if not (x, y) in tiles: + if (x, y) not in tiles: tiles.append((x, y)) return tiles diff --git a/games/wildflowers/scripts/ramps.py b/games/wildflowers/scripts/ramps.py index d225cc6..349e4d3 100644 --- a/games/wildflowers/scripts/ramps.py +++ b/games/wildflowers/scripts/ramps.py @@ -1,53 +1,52 @@ - import random # wildflowers palette ramp definitions PALETTE_RAMPS = { # palette name : list of its ramps - 'dpaint': [ + "dpaint": [ # ramp tuple: (start index, length, stride) # generally, lighter / more vivid to darker (17, 16, 1), # white to black (33, 16, 1), # red to black - (49, 8, 1), # white to red - (57, 8, 1), # light orange to dark orange + (49, 8, 1), # white to red + (57, 8, 1), # light orange to dark orange (65, 16, 1), # light yellow to ~black - (81, 8, 1), # light green to green + (81, 8, 1), # light green to green (89, 24, 1), # white to green to ~black - (113, 16, 1), # light cyan to ~black + (113, 16, 1), # light cyan to ~black (129, 8, 1), # light blue to blue - (137, 24, 1), # white to blue to ~black - (161, 16, 1), # light purple to ~black - (177, 16, 1), # light magenta to ~black - (193, 24, 1), # pale flesh to ~black - (225, 22, 1) # ROYGBV rainbow + (137, 24, 1), # white to blue to ~black + (161, 16, 1), # light purple to ~black + (177, 16, 1), # light magenta to ~black + (193, 24, 1), # pale flesh to ~black + (225, 22, 1), # ROYGBV rainbow ], - 'doom': [ + "doom": [ (17, 27, 1), # very light pink to dark red (44, 20, 1), # pale flesh to brown (69, 26, 1), # white to very dark grey (95, 14, 1), # bright green to ~black - (109, 12, 1), # light tan to dark tan + (109, 12, 1), # light tan to dark tan (126, 4, 1), # olive drab (130, 7, 1), # light gold to gold brown - (137, 18, 1), # white to dark red - (155, 14, 1), # white to dark blue - (169, 11, 1), # white to orange + (137, 18, 1), # white to dark red + (155, 14, 1), # white to dark blue + (169, 11, 1), # white to orange (180, 7, 1), # white to yellow (187, 4, 1), # orange to burnt orange (193, 7, 1), # dark blue to black - (201, 5, 1) # light magenta to dark purple + (201, 5, 1), # light magenta to dark purple ], - 'quake': [ + "quake": [ (16, 15, -1), # white to black (32, 16, -1), # mustard to black (48, 16, -1), # lavender to black (63, 15, -1), # olive to black (79, 16, -1), # red to black (92, 13, -1), # orange to ~black - (108, 16, -1), # yellow to orange to ~black - (124, 16, -1), # pale flesh to ~black + (108, 16, -1), # yellow to orange to ~black + (124, 16, -1), # pale flesh to ~black (125, 16, 1), # light purple to ~black (141, 13, 1), # purpleish pink to ~black (154, 15, 1), # light tan to ~black @@ -57,48 +56,47 @@ PALETTE_RAMPS = { (233, 4, -1), # yellow to brown (236, 3, -1), # light blue to blue (240, 4, -1), # red to dark red - (243, 3, -1) # white to yellow + (243, 3, -1), # white to yellow ], - 'heretic': [ + "heretic": [ (35, 35, -1), # white to black (51, 16, -1), # light grey to dark grey (65, 14, -1), # white to dark violent-grey (94, 29, -1), # white to dark brown - (110, 16, -1), # light tan to brown - (136, 26, -1), # light yellow to dark golden brown + (110, 16, -1), # light tan to brown + (136, 26, -1), # light yellow to dark golden brown (144, 8, -1), # yellow to orange - (160, 16, -1), # red to dark red + (160, 16, -1), # red to dark red (168, 8, -1), # white to pink (176, 8, -1), # light magenta to dark magenta (184, 8, -1), # white to purple - (208, 24, -1), # white to cyan to dark blue - (224, 16, -1), # light green to dark green - (240, 16, -1), # olive to dark olive - (247, 7, -1) # red to yellow + (208, 24, -1), # white to cyan to dark blue + (224, 16, -1), # light green to dark green + (240, 16, -1), # olive to dark olive + (247, 7, -1), # red to yellow + ], + "atari": [ + (113, 8, -16), # white to black + (114, 8, -16), # yellow to muddy brown + (115, 8, -16), # dull gold to brown + (116, 8, -16), # peach to burnt orange + (117, 8, -16), # pink to red + (118, 8, -16), # magenta to dark magenta + (119, 8, -16), # purple to dark purple + (120, 8, -16), # violet to dark violet + (121, 8, -16), # light blue to dark blue + (122, 8, -16), # light cobalt to dark cobalt + (123, 8, -16), # light teal to dark teal + (124, 8, -16), # light sea green to dark sea green + (125, 8, -16), # light green to dark green + (126, 8, -16), # yellow green to dark yellow green + (127, 8, -16), # pale yellow to dark olive + (128, 8, -16), # gold to golden brown ], - 'atari': [ - (113, 8, -16), # white to black - (114, 8, -16), # yellow to muddy brown - (115, 8, -16), # dull gold to brown - (116, 8, -16), # peach to burnt orange - (117, 8, -16), # pink to red - (118, 8, -16), # magenta to dark magenta - (119, 8, -16), # purple to dark purple - (120, 8, -16), # violet to dark violet - (121, 8, -16), # light blue to dark blue - (122, 8, -16), # light cobalt to dark cobalt - (123, 8, -16), # light teal to dark teal - (124, 8, -16), # light sea green to dark sea green - (125, 8, -16), # light green to dark green - (126, 8, -16), # yellow green to dark yellow green - (127, 8, -16), # pale yellow to dark olive - (128, 8, -16) # gold to golden brown - ] } class RampIterator: - def __init__(self, flower): ramp_def = random.choice(PALETTE_RAMPS[flower.art.palette.name]) self.start, self.length, self.stride = ramp_def @@ -106,7 +104,7 @@ class RampIterator: # determine starting color, somewhere along ramp self.start_step = random.randint(0, self.length - 1) self.color = self.start + (self.start_step * self.stride) - + def go_to_next_color(self): self.color += self.stride return self.color diff --git a/games/wildflowers/scripts/wildflowers.py b/games/wildflowers/scripts/wildflowers.py index 5a302aa..5d0fb1c 100644 --- a/games/wildflowers/scripts/wildflowers.py +++ b/games/wildflowers/scripts/wildflowers.py @@ -1,8 +1,5 @@ - -from game_util_objects import WorldGlobalsObject, GameObject -from image_export import export_animation, export_still_image - -from games.wildflowers.scripts.flower import FlowerObject +from game_util_objects import GameObject, WorldGlobalsObject +from image_export import export_still_image """ overall approach: @@ -22,63 +19,65 @@ character ramps based on direction changes, visual density, something else? class FlowerGlobals(WorldGlobalsObject): - # if True, generate a 4x4 grid instead of just one test_gen = False handle_key_events = True - + def __init__(self, world, obj_data=None): WorldGlobalsObject.__init__(self, world, obj_data) - + def pre_first_update(self): - #self.app.can_edit = False + # self.app.can_edit = False self.app.ui.set_game_edit_ui_visibility(False) - self.app.ui.message_line.post_line('') + self.app.ui.message_line.post_line("") if self.test_gen: for x in range(4): for y in range(4): - flower = self.world.spawn_object_of_class('FlowerObject') + flower = self.world.spawn_object_of_class("FlowerObject") flower.set_loc(x * flower.art.width, y * flower.art.height) self.world.camera.set_loc(25, 25, 35) else: - flower = self.world.spawn_object_of_class('FlowerObject') + flower = self.world.spawn_object_of_class("FlowerObject") self.world.camera.set_loc(0, 0, 10) self.flower = flower - self.world.spawn_object_of_class('SeedDisplay') - + self.world.spawn_object_of_class("SeedDisplay") + def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed): - if key != 'e': + if key != "e": return if not self.flower: return - + # save to .psci # hold on last frame self.flower.exportable_art.frame_delays[-1] = 6.0 self.flower.exportable_art.save_to_file() # TODO: investigate why opening for edit puts art mode in a bad state - #self.app.load_art_for_edit(self.flower.exportable_art.filename) - + # self.app.load_art_for_edit(self.flower.exportable_art.filename) + # save to .gif - TODO investigate problem with frame deltas not clearing - #export_animation(self.app, self.flower.exportable_art, + # export_animation(self.app, self.flower.exportable_art, # self.flower.export_filename + '.gif', # bg_color=self.world.bg_color, loop=False) - + # export to .png - works - export_still_image(self.app, self.flower.exportable_art, - self.flower.export_filename + '.png', - crt=self.app.fb.crt, scale=4, - bg_color=self.world.bg_color) - self.app.log('Exported %s.png' % self.flower.export_filename) + export_still_image( + self.app, + self.flower.exportable_art, + self.flower.export_filename + ".png", + crt=self.app.fb.crt, + scale=4, + bg_color=self.world.bg_color, + ) + self.app.log("Exported %s.png" % self.flower.export_filename) class SeedDisplay(GameObject): - generate_art = True art_width, art_height = 30, 1 - art_charset = 'ui' - art_palette = 'c64_original' - + art_charset = "ui" + art_palette = "c64_original" + def __init__(self, world, obj_data=None): GameObject.__init__(self, world, obj_data) self.art.clear_frame_layer(0, 0) diff --git a/grid.py b/grid.py index 0a0ea2e..38a8d16 100644 --- a/grid.py +++ b/grid.py @@ -8,15 +8,15 @@ AXIS_COLOR = (0.8, 0.8, 0.8, 0.5) BASE_COLOR = (0.5, 0.5, 0.5, 0.25) EXTENTS_COLOR = (0, 0, 0, 1) + class Grid(LineRenderable): - visible = True draw_axes = False - + def get_tile_size(self): "Returns (width, height) grid size in tiles." return 1, 1 - + def build_geo(self): "build vert, element, and color arrays" w, h = self.get_tile_size() @@ -28,7 +28,7 @@ class Grid(LineRenderable): index = 4 # axes - Y and X if self.draw_axes: - v += [(w/2, -h), (w/2, 0), (0, -h/2), (w, -h/2)] + v += [(w / 2, -h), (w / 2, 0), (0, -h / 2), (w, -h / 2)] e += [4, 5, 6, 7] color = AXIS_COLOR c += color * 4 @@ -37,69 +37,67 @@ class Grid(LineRenderable): color = BASE_COLOR for x in range(1, w): # skip middle line - if not self.draw_axes or x != w/2: + if not self.draw_axes or x != w / 2: v += [(x, -h), (x, 0)] - e += [index, index+1] + e += [index, index + 1] c += color * 2 index += 2 for y in range(1, h): - if not self.draw_axes or y != h/2: + if not self.draw_axes or y != h / 2: v += [(0, -y), (w, -y)] - e += [index, index+1] + e += [index, index + 1] c += color * 2 index += 2 self.vert_array = np.array(v, dtype=np.float32) self.elem_array = np.array(e, dtype=np.uint32) self.color_array = np.array(c, dtype=np.float32) - + def reset_loc(self): self.x = 0 self.y = 0 self.z = 0 - + def reset(self): "macro for convenience - rescale, reposition, update renderable" self.build_geo() self.reset_loc() self.rebind_buffers() - + def update(self): pass - + def get_projection_matrix(self): return self.app.camera.projection_matrix - + def get_view_matrix(self): return self.app.camera.view_matrix class ArtGrid(Grid): - def reset_loc(self): self.x, self.y = 0, 0 self.z = self.app.ui.active_art.layers_z[self.app.ui.active_art.active_layer] - + def reset(self): self.quad_size_ref = self.app.ui.active_art Grid.reset(self) - + def get_tile_size(self): return self.app.ui.active_art.width, self.app.ui.active_art.height class GameGrid(Grid): - draw_axes = True base_size = 800 - + def get_tile_size(self): # TODO: dynamically adjust bounds based on furthest away objects? return self.base_size, self.base_size - + def set_base_size(self, new_size): self.base_size = new_size self.reset() - + def reset_loc(self): # center of grid at world zero qw, qh = self.get_quad_size() diff --git a/image_convert.py b/image_convert.py index 1154bde..aca78f2 100644 --- a/image_convert.py +++ b/image_convert.py @@ -1,11 +1,12 @@ +import math +import os.path +import time -import math, os.path, time import numpy as np +from PIL import Image -from PIL import Image, ImageChops, ImageStat - +from lab_color import lab_color_diff, rgb_to_lab from renderable_sprite import SpriteRenderable -from lab_color import rgb_to_lab, lab_color_diff """ notes / future research @@ -23,15 +24,17 @@ https://www.youtube.com/watch?v=L6CkYou6hYU - downsample each block bilinearly, divide each into 4x4 cells, then compare them with similarly bilinearly-downsampled char blocks """ + class ImageConverter: - tiles_per_tick = 1 lab_color_comparison = True # delay in seconds before beginning to convert tiles. # lets eg UI catch up to BitmapImageImporter changes to Art. start_delay = 1.0 - - def __init__(self, app, image_filename, art, bicubic_scale=False, sequence_converter=None): + + def __init__( + self, app, image_filename, art, bicubic_scale=False, sequence_converter=None + ): self.init_success = False image_filename = app.find_filename_path(image_filename) if not image_filename or not os.path.exists(image_filename): @@ -46,7 +49,7 @@ class ImageConverter: # if an ImageSequenceConverter created us, keep a handle to it self.sequence_converter = sequence_converter try: - self.src_img = Image.open(self.image_filename).convert('RGB') + self.src_img = Image.open(self.image_filename).convert("RGB") except: return # if we're part of a sequence, app doesn't need handle directly to us @@ -70,21 +73,24 @@ class ImageConverter: self.src_array = np.reshape(self.src_array, (src_h, src_w)) # convert charmap to 1-bit color for fast value swaps during # block comparison - self.char_img = self.art.charset.image_data.copy().convert('RGB') - bw_pal_img = Image.new('P', (1, 1)) + self.char_img = self.art.charset.image_data.copy().convert("RGB") + bw_pal_img = Image.new("P", (1, 1)) bw_pal = [0, 0, 0, 255, 255, 255] while len(bw_pal) < 256 * 3: bw_pal.append(0) bw_pal_img.putpalette(tuple(bw_pal)) self.char_img = self.char_img.quantize(palette=bw_pal_img) self.char_array = np.fromstring(self.char_img.tobytes(), dtype=np.uint8) - self.char_array = np.reshape(self.char_array, (self.art.charset.image_height, self.art.charset.image_width)) + self.char_array = np.reshape( + self.char_array, + (self.art.charset.image_height, self.art.charset.image_width), + ) # create, size and position image preview preview_img = self.src_img.copy() # remove transparency if source image is a GIF to avoid a PIL crash :[ # TODO: https://github.com/python-pillow/Pillow/issues/1377 - if 'transparency' in preview_img.info: - preview_img.info.pop('transparency') + if "transparency" in preview_img.info: + preview_img.info.pop("transparency") self.preview_sprite = SpriteRenderable(self.app, None, preview_img) # preview image scale takes into account character aspect self.preview_sprite.scale_x = w / (self.char_w / self.art.quad_width) @@ -107,39 +113,45 @@ class ImageConverter: if len(self.char_blocks) > self.art.charset.last_index: break self.init_success = True - + def get_generated_color_diffs(self, colors): # build table of color diffs unique_colors = len(colors) color_diffs = np.zeros((unique_colors, unique_colors), dtype=np.float32) # option: L*a*b color space conversion for greater accuracy - get_color_diff = self.get_lab_color_diff if self.lab_color_comparison else self.get_rgb_color_diff - #get_color_diff = self.get_nonlinear_rgb_color_diff - for i,color in enumerate(colors): - for j,other_color in enumerate(colors): + get_color_diff = ( + self.get_lab_color_diff + if self.lab_color_comparison + else self.get_rgb_color_diff + ) + # get_color_diff = self.get_nonlinear_rgb_color_diff + for i, color in enumerate(colors): + for j, other_color in enumerate(colors): color_diffs[i][j] = get_color_diff(color, other_color) return color_diffs - + def get_rgb_color_diff(self, color1, color2): r = abs(color1[0] - color2[0]) g = abs(color1[1] - color2[1]) b = abs(color1[2] - color2[2]) a = abs(color1[3] - color2[3]) return abs(r + g + b + a) - + def get_lab_color_diff(self, color1, color2): l1, a1, b1 = rgb_to_lab(*color1[:3]) l2, a2, b2 = rgb_to_lab(*color2[:3]) return lab_color_diff(l1, a1, b1, l2, a2, b2) - + def get_nonlinear_rgb_color_diff(self, color1, color2): # from http://www.compuphase.com/cmetric.htm rmean = int((color1[0] + color2[0]) / 2) r = color1[0] - color2[0] g = color1[1] - color2[1] b = color1[2] - color2[2] - return math.sqrt((((512+rmean)*r*r)>>8) + 4*g*g + (((767-rmean)*b*b)>>8)) - + return math.sqrt( + (((512 + rmean) * r * r) >> 8) + 4 * g * g + (((767 - rmean) * b * b) >> 8) + ) + def update(self): if time.time() < self.start_time + self.start_delay: return @@ -152,9 +164,16 @@ class ImageConverter: # but transparency isn't properly supported yet fg = self.art.palette.darkest_index if fg == 0 else fg bg = self.art.palette.darkest_index if bg == 0 else bg - self.art.set_tile_at(self.art.active_frame, self.art.active_layer, - self.x, self.y, char, fg, bg) - #print('set block %s,%s to ch %s fg %s bg %s' % (self.x, self.y, char, fg, bg)) + self.art.set_tile_at( + self.art.active_frame, + self.art.active_layer, + self.x, + self.y, + char, + fg, + bg, + ) + # print('set block %s,%s to ch %s fg %s bg %s' % (self.x, self.y, char, fg, bg)) self.x += 1 if self.x >= self.art.width: self.x = 0 @@ -162,7 +181,7 @@ class ImageConverter: if self.y >= self.art.height: self.finish() break - + def get_color_combos_for_block(self, src_block): """ returns # of unique colors, AND @@ -175,12 +194,12 @@ class ImageConverter: return colors, [] # sort by most to least used colors color_counts = [] - for i,color in enumerate(colors): + for i, color in enumerate(colors): color_counts += [(color, counts[i])] color_counts.sort(key=lambda item: item[1], reverse=True) combos = [] - for color1,count1 in color_counts: - for color2,count2 in color_counts: + for color1, count1 in color_counts: + for color2, count2 in color_counts: if color1 == color2: continue # fg/bg color swap SHOULD be allowed @@ -188,7 +207,7 @@ class ImageConverter: continue combos.append((color1, color2)) return colors, combos - + def get_best_tile_for_block(self, src_block): "returns a (char, fg, bg) tuple for the best match of given block" colors, combos = self.get_color_combos_for_block(src_block) @@ -202,14 +221,14 @@ class ImageConverter: best_char = 0 best_diff = 9999999999999 best_fg, best_bg = 0, 0 - for bg,fg in combos: + for bg, fg in combos: # reset char index before each run through charset char_index = 0 char_array = self.char_array.copy() # replace 1-bit color of char image with fg and bg colors char_array[char_array == 0] = bg char_array[char_array == 1] = fg - for (x0, y0, x1, y1) in self.char_blocks: + for x0, y0, x1, y1 in self.char_blocks: char_block = char_array[y0:y1, x0:x1] # using array of difference values w/ fancy numpy indexing, # sum() it @@ -222,31 +241,34 @@ class ImageConverter: best_diff = diff best_char = char_index best_fg, best_bg = fg, bg - #print('%s is new best char index, diff %s:' % (char_index, diff)) + # print('%s is new best char index, diff %s:' % (char_index, diff)) char_index += 1 # return best (least different to source block) char/fg/bg found - #print('%s is best char index, diff %s:' % (best_char, best_diff)) + # print('%s is best char index, diff %s:' % (best_char, best_diff)) return (best_char, best_fg, best_bg) - + def print_block(self, block, fg, bg): "prints ASCII representation of a block with . and # as white and black" w, h = block.shape - s = '' + s = "" for y in range(h): for x in range(w): if block[y][x] == fg: - s += '#' + s += "#" else: - s += '.' - s += '\n' + s += "." + s += "\n" print(s) - + def finish(self, cancelled=False): self.finished = True if not self.sequence_converter: time_taken = time.time() - self.start_time - verb = 'cancelled' if cancelled else 'finished' - self.app.log('Conversion of image %s %s after %.3f seconds' % (self.image_filename, verb, time_taken)) + verb = "cancelled" if cancelled else "finished" + self.app.log( + "Conversion of image %s %s after %.3f seconds" + % (self.image_filename, verb, time_taken) + ) self.app.converter = None self.preview_sprite = None self.app.update_window_title() diff --git a/image_export.py b/image_export.py index 64d2d70..9085e88 100644 --- a/image_export.py +++ b/image_export.py @@ -1,9 +1,9 @@ -import os from OpenGL import GL -from PIL import Image, ImageChops, GifImagePlugin +from PIL import GifImagePlugin, Image, ImageChops from framebuffer import ExportFramebuffer, ExportFramebufferNoCRT + def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0, 0)): "returns a PIL image of given frame of given art, None on failure" post_fb_class = ExportFramebuffer if allow_crt else ExportFramebufferNoCRT @@ -13,8 +13,14 @@ def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0, w, h = int(w * scale), int(h * scale) # error out if over max texture size if w > app.max_texture_size or h > app.max_texture_size: - app.log("ERROR: Image output size (%s x %s) exceeds your hardware's max supported texture size (%s x %s)!" % (w, h, app.max_texture_size, app.max_texture_size), error=True) - app.log(' Please export at a smaller scale or chop up your artwork :[', error=True) + app.log( + "ERROR: Image output size (%s x %s) exceeds your hardware's max supported texture size (%s x %s)!" + % (w, h, app.max_texture_size, app.max_texture_size), + error=True, + ) + app.log( + " Please export at a smaller scale or chop up your artwork :[", error=True + ) return None # create CRT framebuffer post_fb = post_fb_class(app, w, h) @@ -24,8 +30,12 @@ def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0, GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, render_buffer) GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_RGBA8, w, h) GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, export_fb) - GL.glFramebufferRenderbuffer(GL.GL_DRAW_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, - GL.GL_RENDERBUFFER, render_buffer) + GL.glFramebufferRenderbuffer( + GL.GL_DRAW_FRAMEBUFFER, + GL.GL_COLOR_ATTACHMENT0, + GL.GL_RENDERBUFFER, + render_buffer, + ) GL.glViewport(0, 0, w, h) # do render GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, post_fb.framebuffer) @@ -38,8 +48,9 @@ def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0, post_fb.render() GL.glReadBuffer(GL.GL_COLOR_ATTACHMENT0) # read pixels from it - pixels = GL.glReadPixels(0, 0, w, h, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, - outputType=None) + pixels = GL.glReadPixels( + 0, 0, w, h, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, outputType=None + ) # cleanup / deinit of GL stuff GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0) GL.glViewport(0, 0, app.window_width, app.window_height) @@ -48,10 +59,11 @@ def get_frame_image(app, art, frame, allow_crt=True, scale=1, bg_color=(0, 0, 0, post_fb.destroy() # GL pixel data as numpy array -> bytes for PIL image export pixel_bytes = pixels.flatten().tobytes() - src_img = Image.frombytes(mode='RGBA', size=(w, h), data=pixel_bytes) + src_img = Image.frombytes(mode="RGBA", size=(w, h), data=pixel_bytes) src_img = src_img.transpose(Image.FLIP_TOP_BOTTOM) return src_img + def export_animation(app, art, out_filename, bg_color=None, loop=True): # get list of rendered frame images frames = [] @@ -60,33 +72,38 @@ def export_animation(app, art, out_filename, bg_color=None, loop=True): # if bg color is specified, this isn't art mode; play along if bg_color is not None: f_transp = bg_color - art.palette.colors[0] = (round(bg_color[0] * 255), - round(bg_color[1] * 255), - round(bg_color[2] * 255), - 255) + art.palette.colors[0] = ( + round(bg_color[0] * 255), + round(bg_color[1] * 255), + round(bg_color[2] * 255), + 255, + ) else: # GL wants floats - f_transp = (i_transp[0]/255, i_transp[1]/255, i_transp[2]/255, 1.) + f_transp = (i_transp[0] / 255, i_transp[1] / 255, i_transp[2] / 255, 1.0) for frame in range(art.frames): - frame_img = get_frame_image(app, art, frame, allow_crt=False, - scale=1, bg_color=f_transp) + frame_img = get_frame_image( + app, art, frame, allow_crt=False, scale=1, bg_color=f_transp + ) if bg_color is not None: # if bg color is specified, assume no transparency - frame_img = art.palette.get_palettized_image(frame_img, force_no_transparency=True) + frame_img = art.palette.get_palettized_image( + frame_img, force_no_transparency=True + ) else: frame_img = art.palette.get_palettized_image(frame_img, i_transp[:3]) frames.append(frame_img) # compile frames into animated GIF with proper frame delays # technique thanks to: # https://github.com/python-pillow/Pillow/blob/master/Scripts/gifmaker.py - output_img = open(out_filename, 'wb') - for i,img in enumerate(frames): + output_img = open(out_filename, "wb") + for i, img in enumerate(frames): delay = art.frame_delays[i] * 1000 if i == 0: data = GifImagePlugin.getheader(img)[0] # PIL only wants to write GIF87a for some reason... # welcome to 1989 B] - data[0] = data[0].replace(b'7', b'9') + data[0] = data[0].replace(b"7", b"9") # TODO: loop doesn't work? if bg_color is not None: # if bg color is specified, assume no transparency @@ -95,23 +112,24 @@ def export_animation(app, art, out_filename, bg_color=None, loop=True): else: data += GifImagePlugin.getdata(img, duration=delay) else: - data += GifImagePlugin.getdata(img, duration=delay, - transparency=0, loop=0) + data += GifImagePlugin.getdata( + img, duration=delay, transparency=0, loop=0 + ) for b in data: output_img.write(b) continue - delta = ImageChops.subtract_modulo(img, frames[i-1]) + delta = ImageChops.subtract_modulo(img, frames[i - 1]) # Image.getbbox() rather unhelpfully returns None if no delta dw, dh = delta.size bbox = delta.getbbox() or (0, 0, dw, dh) - for b in GifImagePlugin.getdata(img.crop(bbox), offset=bbox[:2], - duration=delay, transparency=0, - loop=0): + for b in GifImagePlugin.getdata( + img.crop(bbox), offset=bbox[:2], duration=delay, transparency=0, loop=0 + ): output_img.write(b) - output_img.write(b';') + output_img.write(b";") output_img.close() - output_format = 'Animated GIF' - #app.log('%s exported (%s)' % (out_filename, output_format)) + output_format = "Animated GIF" + # app.log('%s exported (%s)' % (out_filename, output_format)) def export_still_image(app, art, out_filename, crt=True, scale=1, bg_color=None): @@ -124,20 +142,20 @@ def export_still_image(app, art, out_filename, crt=True, scale=1, bg_color=None) src_img = get_frame_image(app, art, art.active_frame, crt, scale, bg_color) if not src_img: return False - src_img.save(out_filename, 'PNG') - output_format = '32-bit w/ alpha' + src_img.save(out_filename, "PNG") + output_format = "32-bit w/ alpha" else: # else convert to current palette. # as with aniGIF export, use arbitrary color for transparency i_transp = art.palette.get_random_non_palette_color() - f_transp = (i_transp[0]/255, i_transp[1]/255, i_transp[2]/255, 1.) + f_transp = (i_transp[0] / 255, i_transp[1] / 255, i_transp[2] / 255, 1.0) src_img = get_frame_image(app, art, art.active_frame, False, scale, f_transp) if not src_img: return False output_img = art.palette.get_palettized_image(src_img, i_transp[:3]) - output_img.save(out_filename, 'PNG', transparency=0) - output_format = '8-bit palettized w/ transparency' - #app.log('%s exported (%s)' % (out_filename, output_format)) + output_img.save(out_filename, "PNG", transparency=0) + output_format = "8-bit palettized w/ transparency" + # app.log('%s exported (%s)' % (out_filename, output_format)) return True @@ -153,6 +171,6 @@ def write_thumbnail(app, art_filename, thumb_filename): art.renderables.append(renderable) img = get_frame_image(app, art, 0, allow_crt=False) if img: - img.save(thumb_filename, 'PNG') + img.save(thumb_filename, "PNG") if renderable: renderable.destroy() diff --git a/input_handler.py b/input_handler.py index 479935e..453f40c 100644 --- a/input_handler.py +++ b/input_handler.py @@ -1,29 +1,77 @@ -import ctypes, os, platform -import sdl2 - +import ctypes +import os +import platform from sys import exit -from ui import SCALE_INCREMENT, OIS_WIDTH, OIS_HEIGHT, OIS_FILL -from renderable import LAYER_VIS_FULL, LAYER_VIS_DIM, LAYER_VIS_NONE -from ui_art_dialog import NewArtDialog, SaveAsDialog, QuitUnsavedChangesDialog, CloseUnsavedChangesDialog, RevertChangesDialog, ResizeArtDialog, AddFrameDialog, DuplicateFrameDialog, FrameDelayDialog, FrameDelayAllDialog, FrameIndexDialog, AddLayerDialog, DuplicateLayerDialog, SetLayerNameDialog, SetLayerZDialog, PaletteFromFileDialog, ImportFileDialog, ExportFileDialog, SetCameraZoomDialog, ExportOptionsDialog, OverlayImageOpacityDialog -from ui_game_dialog import NewGameDirDialog, LoadGameStateDialog, SaveGameStateDialog, AddRoomDialog, SetRoomCamDialog, SetRoomEdgeWarpsDialog, SetRoomBoundsObjDialog, RenameRoomDialog -from ui_info_dialog import PagedInfoDialog -from ui_file_chooser_dialog import ArtChooserDialog, CharSetChooserDialog, PaletteChooserDialog, PaletteFromImageChooserDialog, RunArtScriptDialog, OverlayImageFileChooserDialog -from ui_list_operations import LO_NONE, LO_SELECT_OBJECTS, LO_SET_SPAWN_CLASS, LO_LOAD_STATE, LO_SET_ROOM, LO_SET_ROOM_OBJECTS, LO_SET_OBJECT_ROOMS, LO_OPEN_GAME_DIR, LO_SET_ROOM_EDGE_WARP, LO_SET_ROOM_EDGE_WARP, LO_SET_ROOM_EDGE_OBJ, LO_SET_ROOM_CAMERA -from collision import CT_NONE -from art import ART_DIR, ART_FILE_EXTENSION -from key_shifts import NUMLOCK_ON_MAP, NUMLOCK_OFF_MAP +import sdl2 -BINDS_FILENAME = 'binds.cfg' -BINDS_TEMPLATE_FILENAME = 'binds.cfg.default' +from art import ART_DIR, ART_FILE_EXTENSION +from collision import CT_NONE +from key_shifts import NUMLOCK_OFF_MAP, NUMLOCK_ON_MAP +from renderable import LAYER_VIS_DIM, LAYER_VIS_FULL, LAYER_VIS_NONE +from ui import OIS_FILL, OIS_HEIGHT, OIS_WIDTH, SCALE_INCREMENT +from ui_art_dialog import ( + AddFrameDialog, + AddLayerDialog, + CloseUnsavedChangesDialog, + DuplicateFrameDialog, + DuplicateLayerDialog, + ExportFileDialog, + ExportOptionsDialog, + FrameDelayAllDialog, + FrameDelayDialog, + FrameIndexDialog, + ImportFileDialog, + NewArtDialog, + OverlayImageOpacityDialog, + QuitUnsavedChangesDialog, + ResizeArtDialog, + RevertChangesDialog, + SaveAsDialog, + SetCameraZoomDialog, + SetLayerNameDialog, + SetLayerZDialog, +) +from ui_file_chooser_dialog import ( + ArtChooserDialog, + CharSetChooserDialog, + OverlayImageFileChooserDialog, + PaletteChooserDialog, + PaletteFromImageChooserDialog, + RunArtScriptDialog, +) +from ui_game_dialog import ( + AddRoomDialog, + NewGameDirDialog, + RenameRoomDialog, + SaveGameStateDialog, + SetRoomBoundsObjDialog, + SetRoomCamDialog, + SetRoomEdgeWarpsDialog, +) +from ui_list_operations import ( + LO_LOAD_STATE, + LO_OPEN_GAME_DIR, + LO_SELECT_OBJECTS, + LO_SET_OBJECT_ROOMS, + LO_SET_ROOM, + LO_SET_ROOM_CAMERA, + LO_SET_ROOM_EDGE_OBJ, + LO_SET_ROOM_EDGE_WARP, + LO_SET_ROOM_OBJECTS, + LO_SET_SPAWN_CLASS, +) + +BINDS_FILENAME = "binds.cfg" +BINDS_TEMPLATE_FILENAME = "binds.cfg.default" class InputLord: - "sets up key binds and handles input" + wheel_zoom_amount = 3.0 keyboard_zoom_amount = 1.0 - + def __init__(self, app): self.app = app self.ui = self.app.ui @@ -36,19 +84,21 @@ class InputLord: # TODO: better solution is find any binds in template but not binds.cfg # and add em binds_filename = self.app.config_dir + BINDS_FILENAME - binds_outdated = not os.path.exists(binds_filename) or os.path.getmtime(binds_filename) < os.path.getmtime(BINDS_TEMPLATE_FILENAME) + binds_outdated = not os.path.exists(binds_filename) or os.path.getmtime( + binds_filename + ) < os.path.getmtime(BINDS_TEMPLATE_FILENAME) if not binds_outdated and os.path.exists(binds_filename): exec(open(binds_filename).read()) - self.app.log('Loaded key binds from %s' % binds_filename) + self.app.log("Loaded key binds from %s" % binds_filename) else: default_data = open(BINDS_TEMPLATE_FILENAME).readlines()[1:] - new_binds = open(binds_filename, 'w') + new_binds = open(binds_filename, "w") new_binds.writelines(default_data) new_binds.close() - self.app.log('Created new key binds file %s' % binds_filename) - exec(''.join(default_data)) + self.app.log("Created new key binds file %s" % binds_filename) + exec("".join(default_data)) if not self.edit_bind_src: - self.app.log('No bind data found, Is binds.cfg.default present?') + self.app.log("No bind data found, Is binds.cfg.default present?") exit() # associate key + mod combos with methods self.edit_binds = {} @@ -59,9 +109,9 @@ class InputLord: # bind data could be a single item (string) or a list/tuple bind_data = self.edit_bind_src[bind_string] if type(bind_data) is str: - bind_fnames = ['BIND_%s' % bind_data] + bind_fnames = ["BIND_%s" % bind_data] else: - bind_fnames = ['BIND_%s' % s for s in bind_data] + bind_fnames = ["BIND_%s" % s for s in bind_data] bind_functions = [] for bind_fname in bind_fnames: if not hasattr(self, bind_fname): @@ -72,19 +122,24 @@ class InputLord: # TODO: use kewl SDL2 gamepad system js_init = sdl2.SDL_InitSubSystem(sdl2.SDL_INIT_JOYSTICK) if js_init != 0: - self.app.log("SDL2: Couldn't initialize joystick subsystem, code %s" % js_init) + self.app.log( + "SDL2: Couldn't initialize joystick subsystem, code %s" % js_init + ) return sticks = sdl2.SDL_NumJoysticks() - #self.app.log('%s gamepads found' % sticks) + # self.app.log('%s gamepads found' % sticks) self.gamepad = None self.gamepad_left_x, self.gamepad_left_y = 0, 0 # for now, just grab first pad if sticks > 0: pad = sdl2.SDL_JoystickOpen(0) - pad_name = sdl2.SDL_JoystickName(pad).decode('utf-8') + pad_name = sdl2.SDL_JoystickName(pad).decode("utf-8") pad_axes = sdl2.SDL_JoystickNumAxes(pad) pad_buttons = sdl2.SDL_JoystickNumButtons(pad) - self.app.log('Gamepad found: %s with %s axes, %s buttons' % (pad_name, pad_axes, pad_buttons)) + self.app.log( + "Gamepad found: %s with %s axes, %s buttons" + % (pad_name, pad_axes, pad_buttons) + ) self.gamepad = pad # before main loop begins, set initial mouse position - # SDL_GetMouseState returns 0,0 if the mouse hasn't yet moved @@ -99,7 +154,7 @@ class InputLord: # set flag so we know whether handle_input's SDL_GetMouseState result # is accurate :/ self.mouse_has_moved = False - + def parse_key_bind(self, in_string): "returns a tuple of (key, mod1, mod2) key bind data from given string" shift = False @@ -107,22 +162,22 @@ class InputLord: ctrl = False key = None for i in in_string.split(): - if i.lower() == 'shift': + if i.lower() == "shift": shift = True - elif i.lower() == 'alt': + elif i.lower() == "alt": alt = True - elif i.lower() == 'ctrl': + elif i.lower() == "ctrl": ctrl = True else: key = i return (key, shift, alt, ctrl) - + def get_bind_functions(self, keysym, shift, alt, ctrl): "returns a list of methods for the given key + mods if one exists" keystr = sdl2.SDL_GetKeyName(keysym).decode().lower() key_data = (keystr, shift, alt, ctrl) return self.edit_binds.get(key_data, []) - + def get_keysym(self, event): "get SDL2 keysym from event; right now only used to check numlock variants" numlock_on = bool(event.key.keysym.mod & sdl2.KMOD_NUM) @@ -133,26 +188,26 @@ class InputLord: elif not numlock_on and keysym in NUMLOCK_OFF_MAP: return NUMLOCK_OFF_MAP[keysym] return keysym - + def get_command_shortcut(self, command_function): for bind in self.edit_bind_src: if command_function == self.edit_bind_src[bind]: return bind - return '' - + return "" + def get_menu_items_for_command_function(self, function): # search both menus for items; command checks buttons = self.ui.art_menu_bar.menu_buttons + self.ui.game_menu_bar.menu_buttons items = [] for button in buttons: # skip eg playscii button - if not hasattr(button, 'menu_data'): + if not hasattr(button, "menu_data"): continue for item in button.menu_data.items: - if function.__name__ == 'BIND_%s' % item.command: + if function.__name__ == "BIND_%s" % item.command: items.append(item) return items - + def is_command_function_allowed(self, function): "returns True if given function's menu bar item is available" items = self.get_menu_items_for_command_function(function) @@ -167,7 +222,7 @@ class InputLord: if item.game_mode_allowed and self.app.game_mode: return True return False - + def handle_input(self): app = self.app # get and store mouse state @@ -208,7 +263,9 @@ class InputLord: ms = sdl2.SDL_GetModState() self.capslock_on = bool(ms & sdl2.KMOD_CAPS) # macOS: treat command as interchangeable with control, is this kosher? - if platform.system() == 'Darwin' and (ks[sdl2.SDL_SCANCODE_LGUI] or ks[sdl2.SDL_SCANCODE_RGUI]): + if platform.system() == "Darwin" and ( + ks[sdl2.SDL_SCANCODE_LGUI] or ks[sdl2.SDL_SCANCODE_RGUI] + ): self.ctrl_pressed = True if app.capslock_is_ctrl and ks[sdl2.SDL_SCANCODE_CAPSLOCK]: self.ctrl_pressed = True @@ -216,8 +273,14 @@ class InputLord: mods = self.shift_pressed, self.alt_pressed, self.ctrl_pressed # get controller state if self.gamepad: - self.gamepad_left_x = sdl2.SDL_JoystickGetAxis(self.gamepad, sdl2.SDL_CONTROLLER_AXIS_LEFTX) / 32768 - self.gamepad_left_y = sdl2.SDL_JoystickGetAxis(self.gamepad, sdl2.SDL_CONTROLLER_AXIS_LEFTY) / -32768 + self.gamepad_left_x = ( + sdl2.SDL_JoystickGetAxis(self.gamepad, sdl2.SDL_CONTROLLER_AXIS_LEFTX) + / 32768 + ) + self.gamepad_left_y = ( + sdl2.SDL_JoystickGetAxis(self.gamepad, sdl2.SDL_CONTROLLER_AXIS_LEFTY) + / -32768 + ) for event in sdl2.ext.get_events(): if event.type == sdl2.SDL_QUIT: app.should_quit = True @@ -239,13 +302,19 @@ class InputLord: if self.ui.console.visible: self.ui.console.handle_input(keysym, *mods) # same with dialog box - elif self.ui.active_dialog and self.ui.active_dialog is self.ui.keyboard_focus_element: + elif ( + self.ui.active_dialog + and self.ui.active_dialog is self.ui.keyboard_focus_element + ): self.ui.active_dialog.handle_input(keysym, *mods) # bail, process no further input - #sdl2.SDL_PumpEvents() - #return + # sdl2.SDL_PumpEvents() + # return # handle text input if text tool is active - elif self.ui.selected_tool is self.ui.text_tool and self.ui.text_tool.input_active: + elif ( + self.ui.selected_tool is self.ui.text_tool + and self.ui.text_tool.input_active + ): self.ui.text_tool.handle_keyboard_input(keysym, *mods) # see if there's a function for this bind and run it else: @@ -272,7 +341,11 @@ class InputLord: # keyup shouldn't have any special meaning in a dialog pass elif self.BIND_game_grab in flist: - if self.app.game_mode and not self.ui.active_dialog and self.app.gw.player: + if ( + self.app.game_mode + and not self.ui.active_dialog + and self.app.gw.player + ): self.app.gw.player.button_unpressed(0) return elif self.BIND_toggle_picker in flist: @@ -281,7 +354,10 @@ class InputLord: self.ui.popup.hide() elif self.BIND_select_or_paint in flist: app.keyboard_editing = True - if not self.ui.selected_tool is self.ui.text_tool and not self.ui.text_tool.input_active: + if ( + self.ui.selected_tool is not self.ui.text_tool + and not self.ui.text_tool.input_active + ): self.app.cursor.finish_paint() # # mouse events aren't handled by bind table for now @@ -292,8 +368,9 @@ class InputLord: if self.app.can_edit: if event.wheel.y > 0: # only zoom in should track towards cursor - app.camera.zoom(-self.wheel_zoom_amount, - towards_cursor=True) + app.camera.zoom( + -self.wheel_zoom_amount, towards_cursor=True + ) elif event.wheel.y < 0: app.camera.zoom(self.wheel_zoom_amount) else: @@ -308,14 +385,26 @@ class InputLord: self.app.gw.unclicked(event.button.button) # LMB up: finish paint for most tools, end select drag if event.button.button == sdl2.SDL_BUTTON_LEFT: - if self.ui.selected_tool is self.ui.select_tool and self.ui.select_tool.selection_in_progress: - self.ui.select_tool.finish_select(self.shift_pressed, self.ctrl_pressed) - elif not self.ui.selected_tool is self.ui.text_tool and not self.ui.text_tool.input_active: + if ( + self.ui.selected_tool is self.ui.select_tool + and self.ui.select_tool.selection_in_progress + ): + self.ui.select_tool.finish_select( + self.shift_pressed, self.ctrl_pressed + ) + elif ( + self.ui.selected_tool is not self.ui.text_tool + and not self.ui.text_tool.input_active + ): app.cursor.finish_paint() elif event.type == sdl2.SDL_MOUSEBUTTONDOWN: ui_clicked = self.ui.clicked(event.button.button) # don't register edit commands if a menu is up - if ui_clicked or self.ui.menu_bar.active_menu_name or self.ui.active_dialog: + if ( + ui_clicked + or self.ui.menu_bar.active_menu_name + or self.ui.active_dialog + ): sdl2.SDL_PumpEvents() if self.app.game_mode: self.app.gw.last_click_on_ui = True @@ -330,16 +419,21 @@ class InputLord: return elif self.ui.selected_tool is self.ui.text_tool: # text tool: only start entry if click is outside popup - if not self.ui.text_tool.input_active and \ - not self.ui.popup in self.ui.hovered_elements: + if ( + not self.ui.text_tool.input_active + and self.ui.popup not in self.ui.hovered_elements + ): self.ui.text_tool.start_entry() elif self.ui.selected_tool is self.ui.select_tool: # select tool: accept clicks if they're outside the popup - if not self.ui.select_tool.selection_in_progress and \ - (not self.ui.keyboard_focus_element or \ - (self.ui.keyboard_focus_element is self.ui.popup and \ - not self.ui.popup in self.ui.hovered_elements)): - self.ui.select_tool.start_select() + if not self.ui.select_tool.selection_in_progress and ( + not self.ui.keyboard_focus_element + or ( + self.ui.keyboard_focus_element is self.ui.popup + and self.ui.popup not in self.ui.hovered_elements + ) + ): + self.ui.select_tool.start_select() else: app.cursor.start_paint() elif event.button.button == sdl2.SDL_BUTTON_RIGHT: @@ -349,20 +443,49 @@ class InputLord: if self.ui.active_dialog: sdl2.SDL_PumpEvents() return + # directly query keys we don't want affected by OS key repeat delay # TODO: these are hard-coded for the moment, think of a good way # to expose this functionality to the key bind system def pressing_up(ks): - return ks[sdl2.SDL_SCANCODE_W] or ks[sdl2.SDL_SCANCODE_UP] or ks[sdl2.SDL_SCANCODE_KP_8] + return ( + ks[sdl2.SDL_SCANCODE_W] + or ks[sdl2.SDL_SCANCODE_UP] + or ks[sdl2.SDL_SCANCODE_KP_8] + ) + def pressing_down(ks): - return ks[sdl2.SDL_SCANCODE_S] or ks[sdl2.SDL_SCANCODE_DOWN] or ks[sdl2.SDL_SCANCODE_KP_2] + return ( + ks[sdl2.SDL_SCANCODE_S] + or ks[sdl2.SDL_SCANCODE_DOWN] + or ks[sdl2.SDL_SCANCODE_KP_2] + ) + def pressing_left(ks): - return ks[sdl2.SDL_SCANCODE_A] or ks[sdl2.SDL_SCANCODE_LEFT] or ks[sdl2.SDL_SCANCODE_KP_4] + return ( + ks[sdl2.SDL_SCANCODE_A] + or ks[sdl2.SDL_SCANCODE_LEFT] + or ks[sdl2.SDL_SCANCODE_KP_4] + ) + def pressing_right(ks): - return ks[sdl2.SDL_SCANCODE_D] or ks[sdl2.SDL_SCANCODE_RIGHT] or ks[sdl2.SDL_SCANCODE_KP_6] + return ( + ks[sdl2.SDL_SCANCODE_D] + or ks[sdl2.SDL_SCANCODE_RIGHT] + or ks[sdl2.SDL_SCANCODE_KP_6] + ) + # prevent camera move if: console is up, text input is active, editing # is not allowed - if self.shift_pressed and not self.alt_pressed and not self.ctrl_pressed and not self.ui.console.visible and not self.ui.text_tool.input_active and self.app.can_edit and self.ui.keyboard_focus_element is None: + if ( + self.shift_pressed + and not self.alt_pressed + and not self.ctrl_pressed + and not self.ui.console.visible + and not self.ui.text_tool.input_active + and self.app.can_edit + and self.ui.keyboard_focus_element is None + ): if pressing_up(ks): app.camera.pan(0, 1, True) if pressing_down(ks): @@ -372,14 +495,24 @@ class InputLord: if pressing_right(ks): app.camera.pan(1, 0, True) if ks[sdl2.SDL_SCANCODE_X]: - app.camera.zoom(-self.keyboard_zoom_amount, keyboard=True, - towards_cursor=True) + app.camera.zoom( + -self.keyboard_zoom_amount, keyboard=True, towards_cursor=True + ) if ks[sdl2.SDL_SCANCODE_Z]: app.camera.zoom(self.keyboard_zoom_amount, keyboard=True) - if self.app.can_edit and app.middle_mouse and (app.mouse_dx != 0 or app.mouse_dy != 0): + if ( + self.app.can_edit + and app.middle_mouse + and (app.mouse_dx != 0 or app.mouse_dy != 0) + ): app.camera.mouse_pan(app.mouse_dx, app.mouse_dy) # game mode: arrow keys and left gamepad stick move player - if self.app.game_mode and not self.ui.console.visible and not self.ui.active_dialog and self.ui.keyboard_focus_element is None: + if ( + self.app.game_mode + and not self.ui.console.visible + and not self.ui.active_dialog + and self.ui.keyboard_focus_element is None + ): if pressing_up(ks): # shift = move selected if self.shift_pressed and self.app.can_edit: @@ -406,13 +539,13 @@ class InputLord: if abs(self.gamepad_left_y) > 0.15 and app.gw.player: app.gw.player.move(0, self.gamepad_left_y) sdl2.SDL_PumpEvents() - + def is_key_pressed(self, key): "returns True if given key is pressed" - key = bytes(key, encoding='utf-8') + key = bytes(key, encoding="utf-8") scancode = sdl2.keyboard.SDL_GetScancodeFromName(key) return sdl2.SDL_GetKeyboardState(None)[scancode] - + # # bind functions # @@ -426,16 +559,16 @@ class InputLord: self.ui.open_dialog(QuitUnsavedChangesDialog) return self.app.should_quit = True - + def BIND_toggle_console(self): self.ui.console.toggle() - + def BIND_import_file(self): self.ui.open_dialog(ImportFileDialog) - + def BIND_export_file(self): self.ui.open_dialog(ExportFileDialog) - + def BIND_export_file_last(self): # if user hasn't exported this session, pick an exporter if self.ui.app.exporter: @@ -443,104 +576,105 @@ class InputLord: out_filename = self.ui.active_art.filename out_filename = os.path.basename(out_filename) out_filename = os.path.splitext(out_filename)[0] - ExportOptionsDialog.do_export(self.ui.app, out_filename, - self.ui.app.last_export_options) + ExportOptionsDialog.do_export( + self.ui.app, out_filename, self.ui.app.last_export_options + ) else: self.ui.open_dialog(ExportFileDialog) - + def BIND_decrease_ui_scale(self): if self.ui.scale > SCALE_INCREMENT * 2: self.ui.set_scale(self.ui.scale - SCALE_INCREMENT) - + def BIND_increase_ui_scale(self): # cap UI scale at 2 if self.ui.scale + SCALE_INCREMENT < 2.0: self.ui.set_scale(self.ui.scale + SCALE_INCREMENT) - + def BIND_toggle_fullscreen(self): self.app.toggle_fullscreen() - + def BIND_decrease_brush_size(self): self.ui.selected_tool.decrease_brush_size() self.ui.menu_bar.refresh_active_menu() - + def BIND_increase_brush_size(self): self.ui.selected_tool.increase_brush_size() self.ui.menu_bar.refresh_active_menu() - + def BIND_cycle_char_forward(self): - self.ui.select_char(self.ui.selected_char+1) - + self.ui.select_char(self.ui.selected_char + 1) + def BIND_cycle_char_backward(self): - self.ui.select_char(self.ui.selected_char-1) - + self.ui.select_char(self.ui.selected_char - 1) + def BIND_cycle_fg_forward(self): - self.ui.select_fg(self.ui.selected_fg_color+1) - + self.ui.select_fg(self.ui.selected_fg_color + 1) + def BIND_cycle_fg_backward(self): - self.ui.select_fg(self.ui.selected_fg_color-1) - + self.ui.select_fg(self.ui.selected_fg_color - 1) + def BIND_cycle_bg_forward(self): - self.ui.select_bg(self.ui.selected_bg_color+1) - + self.ui.select_bg(self.ui.selected_bg_color + 1) + def BIND_cycle_bg_backward(self): - self.ui.select_bg(self.ui.selected_bg_color-1) - + self.ui.select_bg(self.ui.selected_bg_color - 1) + def BIND_cycle_xform_forward(self): self.ui.cycle_selected_xform() - + def BIND_cycle_xform_backward(self): self.ui.cycle_selected_xform(True) - + def BIND_toggle_affects_char(self): self.ui.selected_tool.toggle_affects_char() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_affects_fg(self): self.ui.selected_tool.toggle_affects_fg() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_affects_bg(self): self.ui.selected_tool.toggle_affects_bg() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_affects_xform(self): self.ui.selected_tool.toggle_affects_xform() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_crt(self): self.app.fb.toggle_crt() self.ui.menu_bar.refresh_active_menu() - + def BIND_select_pencil_tool(self): self.ui.set_selected_tool(self.ui.pencil_tool) - + def BIND_select_erase_tool(self): self.ui.set_selected_tool(self.ui.erase_tool) - + def BIND_select_rotate_tool(self): self.ui.set_selected_tool(self.ui.rotate_tool) - + def BIND_select_grab_tool(self): self.ui.set_selected_tool(self.ui.grab_tool) - + def BIND_select_text_tool(self): self.ui.set_selected_tool(self.ui.text_tool) - + def BIND_select_select_tool(self): self.ui.set_selected_tool(self.ui.select_tool) - + def BIND_select_fill_tool(self): self.ui.set_selected_tool(self.ui.fill_tool) - + def BIND_cycle_fill_boundary_mode(self): self.ui.cycle_fill_tool_mode() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_art_toolbar(self): self.ui.art_toolbar.visible = not self.ui.art_toolbar.visible self.ui.menu_bar.refresh_active_menu() - + def BIND_cut_selection(self): self.ui.cut_selection() # switch to PasteTool @@ -548,7 +682,7 @@ class InputLord: # clear selection self.ui.select_none() self.ui.tool_settings_changed = True - + def BIND_copy_selection(self): self.ui.copy_selection() # switch to PasteTool @@ -556,16 +690,16 @@ class InputLord: # clear selection self.ui.select_none() self.ui.tool_settings_changed = True - + def BIND_select_paste_tool(self): self.ui.set_selected_tool(self.ui.paste_tool) - + def BIND_select_none(self): if self.app.game_mode: self.app.gw.deselect_all() else: self.ui.select_none() - + def BIND_cancel(self): # context-dependent: # game mode: deselect @@ -584,17 +718,17 @@ class InputLord: self.app.gw.deselect_all() else: self.ui.select_none() - + def BIND_select_all(self): self.ui.select_all() - + def BIND_select_invert(self): self.ui.invert_selection() - + def BIND_edit_cfg(self): self.ui.menu_bar.close_active_menu() self.app.edit_cfg() - + def BIND_erase_selection_or_art(self): # if in game mode, delete selected objects if self.app.game_mode: @@ -606,7 +740,7 @@ class InputLord: obj.destroy() else: self.ui.erase_selection_or_art() - + def BIND_toggle_game_mode(self): if not self.app.can_edit: return @@ -615,25 +749,25 @@ class InputLord: else: self.app.exit_game_mode() self.app.update_window_title() - + def BIND_new_game_dir(self): self.ui.open_dialog(NewGameDirDialog) - + def BIND_set_game_dir(self): if self.app.can_edit: # show available games in list panel self.ui.edit_list_panel.set_list_operation(LO_OPEN_GAME_DIR) - + def BIND_load_game_state(self): self.ui.edit_list_panel.set_list_operation(LO_LOAD_STATE) - + def BIND_save_game_state(self): self.ui.open_dialog(SaveGameStateDialog) - + def BIND_reset_game(self): if self.app.game_mode and self.app.gw.game_dir: self.app.gw.reset_game() - + def BIND_toggle_picker(self): if not self.ui.active_art: return @@ -641,15 +775,15 @@ class InputLord: self.ui.popup.show() else: self.ui.popup.toggle() - + def BIND_toggle_picker_hold(self): self.ui.popup_hold_to_show = not self.ui.popup_hold_to_show self.ui.menu_bar.refresh_active_menu() - + def BIND_swap_fg_bg_colors(self): if self.ui.active_art: self.ui.swap_fg_bg_colors() - + def BIND_save_current(self): # save current game state in game mode, else save current art if self.app.game_mode and self.app.gw.game_dir: @@ -657,29 +791,29 @@ class InputLord: self.app.gw.save_last_state() elif self.ui.active_art: # if new document, ask for a name - default_name = ART_DIR + 'new.' + ART_FILE_EXTENSION + default_name = ART_DIR + "new." + ART_FILE_EXTENSION if self.ui.active_art.filename == default_name: self.ui.open_dialog(SaveAsDialog) else: self.ui.active_art.save_to_file() - + def BIND_toggle_ui_visibility(self): self.ui.visible = not self.ui.visible - + def BIND_toggle_grid_visibility(self): self.app.grid.visible = not self.app.grid.visible self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_bg_texture(self): self.app.show_bg_texture = not self.app.show_bg_texture self.ui.menu_bar.refresh_active_menu() - + def BIND_previous_frame(self): self.ui.set_active_frame(self.ui.active_art.active_frame - 1) - + def BIND_next_frame(self): self.ui.set_active_frame(self.ui.active_art.active_frame + 1) - + def BIND_toggle_anim_playback(self): # if game mode, pause/unpause if self.app.game_mode: @@ -691,93 +825,93 @@ class InputLord: else: r.start_animating() self.ui.menu_bar.refresh_active_menu() - + def toggle_pause(self): self.app.gw.toggle_pause() self.ui.menu_bar.refresh_active_menu() - + def BIND_previous_layer(self): self.ui.set_active_layer(self.ui.active_art.active_layer - 1) self.ui.menu_bar.refresh_active_menu() - + def BIND_next_layer(self): self.ui.set_active_layer(self.ui.active_art.active_layer + 1) self.ui.menu_bar.refresh_active_menu() - + def BIND_previous_art(self): self.ui.previous_active_art() self.ui.menu_bar.refresh_active_menu() - + def BIND_next_art(self): if len(self.app.art_loaded_for_edit) == 0: return self.ui.next_active_art() self.ui.menu_bar.refresh_active_menu() - + def BIND_undo(self): self.ui.undo() - + def BIND_redo(self): self.ui.redo() - + def BIND_quick_grab(self): if not self.ui.active_art: return self.app.keyboard_editing = True self.ui.quick_grab() - + def BIND_set_camera_zoom(self): self.ui.open_dialog(SetCameraZoomDialog) - + def BIND_camera_zoom_in_proportional(self): self.app.camera.zoom_proportional(1) - + def BIND_camera_zoom_out_proportional(self): self.app.camera.zoom_proportional(-1) - + def BIND_toggle_zoom_extents(self): self.app.camera.toggle_zoom_extents() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_camera_tilt(self): if self.app.camera.y_tilt == 2: self.app.camera.y_tilt = 0 - self.ui.message_line.post_line('Camera tilt disengaged.') + self.ui.message_line.post_line("Camera tilt disengaged.") else: self.app.camera.y_tilt = 2 - self.ui.message_line.post_line('Camera tilt engaged.') + self.ui.message_line.post_line("Camera tilt engaged.") self.ui.menu_bar.refresh_active_menu() - + def BIND_select_overlay_image(self): self.ui.open_dialog(OverlayImageFileChooserDialog) - + def BIND_toggle_overlay_image(self): self.app.draw_overlay = not self.app.draw_overlay self.ui.menu_bar.refresh_active_menu() - + def BIND_set_overlay_image_opacity(self): self.ui.open_dialog(OverlayImageOpacityDialog) - + def BIND_set_overlay_image_scaling(self): if self.app.overlay_scale_type == OIS_WIDTH: - self.app.overlay_scale_type = OIS_HEIGHT + self.app.overlay_scale_type = OIS_HEIGHT elif self.app.overlay_scale_type == OIS_HEIGHT: - self.app.overlay_scale_type = OIS_FILL + self.app.overlay_scale_type = OIS_FILL elif self.app.overlay_scale_type == OIS_FILL: self.app.overlay_scale_type = OIS_WIDTH self.ui.size_and_position_overlay_image() self.ui.menu_bar.refresh_active_menu() - + def BIND_add_to_list_selection(self): if not self.ui.edit_list_panel.is_visible(): return self.ui.edit_list_panel.keyboard_select_item() - + def BIND_remove_from_list_selection(self): if not self.ui.edit_list_panel.is_visible(): return self.ui.edit_list_panel.keyboard_select_item() - + def BIND_select_or_paint(self): if self.ui.keyboard_focus_element: # save current focus element because kb_select_item might change it! @@ -791,7 +925,10 @@ class InputLord: return if not self.ui.active_art: return - elif self.ui.selected_tool is self.ui.text_tool and not self.ui.text_tool.input_active: + elif ( + self.ui.selected_tool is self.ui.text_tool + and not self.ui.text_tool.input_active + ): self.ui.text_tool.start_entry() elif self.ui.selected_tool is self.ui.select_tool: if self.ui.select_tool.selection_in_progress: @@ -801,107 +938,107 @@ class InputLord: self.ui.select_tool.start_select() else: self.app.cursor.start_paint() - + def BIND_screenshot(self): self.app.screenshot() - + def BIND_run_test_mutate(self): - if self.ui.active_art.is_script_running('conway'): - self.ui.active_art.stop_script('conway') + if self.ui.active_art.is_script_running("conway"): + self.ui.active_art.stop_script("conway") else: - self.ui.active_art.run_script_every('conway', 0.05) - + self.ui.active_art.run_script_every("conway", 0.05) + def BIND_arrow_up(self): if self.ui.keyboard_focus_element: self.ui.keyboard_navigate(0, -1) else: self.app.cursor.keyboard_move(0, 1) - + def BIND_arrow_down(self): if self.ui.keyboard_focus_element: self.ui.keyboard_navigate(0, 1) else: self.app.cursor.keyboard_move(0, -1) - + def BIND_arrow_left(self): # navigate popup, menu bar etc if self.ui.keyboard_focus_element: self.ui.keyboard_navigate(-1, 0) else: self.app.cursor.keyboard_move(-1, 0) - + def BIND_arrow_right(self): if self.ui.keyboard_focus_element: self.ui.keyboard_navigate(1, 0) else: self.app.cursor.keyboard_move(1, 0) - + def BIND_cycle_inactive_layer_visibility(self): if not self.ui.active_art: return if self.ui.active_art.layers == 1: return - message_text = 'Non-active layers: ' + message_text = "Non-active layers: " if self.app.inactive_layer_visibility == LAYER_VIS_FULL: self.app.inactive_layer_visibility = LAYER_VIS_DIM - message_text += 'dim' + message_text += "dim" elif self.app.inactive_layer_visibility == LAYER_VIS_DIM: self.app.inactive_layer_visibility = LAYER_VIS_NONE - message_text += 'invisible' + message_text += "invisible" else: self.app.inactive_layer_visibility = LAYER_VIS_FULL - message_text += 'visible' + message_text += "visible" self.ui.message_line.post_line(message_text) self.ui.menu_bar.refresh_active_menu() - + def BIND_open_file_menu(self): - self.ui.menu_bar.open_menu_by_name('file') - + self.ui.menu_bar.open_menu_by_name("file") + def BIND_open_edit_menu(self): - self.ui.menu_bar.open_menu_by_name('edit') - + self.ui.menu_bar.open_menu_by_name("edit") + def BIND_open_tool_menu(self): - self.ui.menu_bar.open_menu_by_name('tool') - + self.ui.menu_bar.open_menu_by_name("tool") + def BIND_open_view_menu(self): - self.ui.menu_bar.open_menu_by_name('view') - + self.ui.menu_bar.open_menu_by_name("view") + def BIND_open_art_menu(self): - self.ui.menu_bar.open_menu_by_name('art') - + self.ui.menu_bar.open_menu_by_name("art") + def BIND_open_frame_menu(self): if self.app.game_mode: - self.ui.menu_bar.open_menu_by_name('room') + self.ui.menu_bar.open_menu_by_name("room") else: - self.ui.menu_bar.open_menu_by_name('frame') - + self.ui.menu_bar.open_menu_by_name("frame") + def BIND_open_layer_menu(self): - self.ui.menu_bar.open_menu_by_name('layer') - + self.ui.menu_bar.open_menu_by_name("layer") + def BIND_open_char_color_menu(self): - self.ui.menu_bar.open_menu_by_name('char_color') - + self.ui.menu_bar.open_menu_by_name("char_color") + def BIND_open_help_menu(self): - self.ui.menu_bar.open_menu_by_name('help') - + self.ui.menu_bar.open_menu_by_name("help") + def BIND_open_game_menu(self): - self.ui.menu_bar.open_menu_by_name('game') - + self.ui.menu_bar.open_menu_by_name("game") + def BIND_open_state_menu(self): - self.ui.menu_bar.open_menu_by_name('state') - + self.ui.menu_bar.open_menu_by_name("state") + def BIND_open_world_menu(self): - self.ui.menu_bar.open_menu_by_name('world') - + self.ui.menu_bar.open_menu_by_name("world") + def BIND_open_object_menu(self): - self.ui.menu_bar.open_menu_by_name('object') - + self.ui.menu_bar.open_menu_by_name("object") + def BIND_new_art(self): self.ui.open_dialog(NewArtDialog) - + def BIND_open_art(self): self.ui.open_dialog(ArtChooserDialog) - + def BIND_save_art_as(self): if self.app.game_mode: self.ui.open_dialog(SaveGameStateDialog) @@ -909,13 +1046,13 @@ class InputLord: return else: self.ui.open_dialog(SaveAsDialog) - + def BIND_revert_art(self): if not self.ui.active_art: return if self.ui.active_art.unsaved_changes: self.ui.open_dialog(RevertChangesDialog) - + def BIND_close_art(self): if not self.ui.active_art: return @@ -926,122 +1063,124 @@ class InputLord: # dismiss popup if no more arts are open if self.ui.popup.visible and len(self.app.art_loaded_for_edit) == 0: self.ui.popup.hide() - + def BIND_open_help_docs(self): self.app.open_help_docs() - + def BIND_generate_docs(self): self.app.generate_docs() - + def BIND_open_website(self): self.app.open_website() - + def BIND_crop_to_selection(self): self.ui.crop_to_selection(self.ui.active_art) - + def BIND_resize_art(self): self.ui.open_dialog(ResizeArtDialog) - + def BIND_art_flip_horizontal(self): - self.ui.active_art.flip_horizontal(self.ui.active_art.active_frame, - self.ui.active_art.active_layer) - + self.ui.active_art.flip_horizontal( + self.ui.active_art.active_frame, self.ui.active_art.active_layer + ) + def BIND_art_flip_vertical(self): - self.ui.active_art.flip_vertical(self.ui.active_art.active_frame, - self.ui.active_art.active_layer) - + self.ui.active_art.flip_vertical( + self.ui.active_art.active_frame, self.ui.active_art.active_layer + ) + def BIND_art_toggle_flip_affects_xforms(self): self.ui.flip_affects_xforms = not self.ui.flip_affects_xforms self.ui.menu_bar.refresh_active_menu() - + def BIND_run_art_script(self): self.ui.open_dialog(RunArtScriptDialog) - + def BIND_run_art_script_last(self): # if user hasn't run a script this session, pick one if not self.ui.app.last_art_script: self.BIND_run_art_script() else: self.ui.active_art.run_script(self.ui.app.last_art_script, log=False) - + def BIND_art_switch_to(self, art_filename): self.ui.set_active_art_by_filename(art_filename) self.ui.menu_bar.refresh_active_menu() - + def BIND_add_frame(self): self.ui.open_dialog(AddFrameDialog) - + def BIND_duplicate_frame(self): self.ui.open_dialog(DuplicateFrameDialog) - + def BIND_change_frame_delay(self): self.ui.open_dialog(FrameDelayDialog) - + def BIND_change_frame_delay_all(self): self.ui.open_dialog(FrameDelayAllDialog) - + def BIND_delete_frame(self): self.ui.active_art.delete_frame_at(self.ui.active_art.active_frame) # if we're now down to 1 frame, refresh to dim this item! # FIXME: this doesn't dim it - why? - but it is unselectable self.ui.menu_bar.refresh_active_menu() - + def BIND_change_frame_index(self): self.ui.open_dialog(FrameIndexDialog) - + def BIND_add_layer(self): self.ui.open_dialog(AddLayerDialog) - + def BIND_duplicate_layer(self): self.ui.open_dialog(DuplicateLayerDialog) - + def BIND_layer_switch_to(self, layer_number): self.ui.set_active_layer(layer_number) self.ui.menu_bar.refresh_active_menu() - + def BIND_change_layer_name(self): self.ui.open_dialog(SetLayerNameDialog) - + def BIND_change_layer_z(self): self.ui.open_dialog(SetLayerZDialog) - + def BIND_toggle_layer_visibility(self): art = self.ui.active_art is_visible = art.layers_visibility[art.active_layer] art.layers_visibility[art.active_layer] = not is_visible art.set_unsaved_changes(True) self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_hidden_layers_visible(self): self.app.show_hidden_layers = not self.app.show_hidden_layers self.ui.menu_bar.refresh_active_menu() - + def BIND_delete_layer(self): self.ui.active_art.delete_layer(self.ui.active_art.active_layer) self.ui.menu_bar.refresh_active_menu() - + def BIND_choose_charset(self): self.ui.open_dialog(CharSetChooserDialog) - + def BIND_choose_palette(self): self.ui.open_dialog(PaletteChooserDialog) - + def BIND_palette_from_file(self): self.ui.open_dialog(PaletteFromImageChooserDialog) - + def BIND_toggle_onion_visibility(self): self.app.onion_frames_visible = not self.app.onion_frames_visible if self.app.onion_frames_visible: self.ui.reset_onion_frames() self.ui.menu_bar.refresh_active_menu() - + def BIND_cycle_onion_frames(self): self.app.onion_show_frames += 1 self.app.onion_show_frames %= self.app.max_onion_frames + 1 # start cycle at 1, not 0 self.app.onion_show_frames = max(1, self.app.onion_show_frames) self.ui.menu_bar.refresh_active_menu() - + def BIND_cycle_onion_ahead_behind(self): # cycle between next, previous, next & previous if self.app.onion_show_frames_behind and self.app.onion_show_frames_ahead: @@ -1053,13 +1192,13 @@ class InputLord: self.app.onion_show_frames_ahead = True self.app.onion_show_frames_ahead = True self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_debug_text(self): self.ui.debug_text.visible = not self.ui.debug_text.visible - + def BIND_toggle_fps_counter(self): self.ui.fps_counter.visible = not self.ui.fps_counter.visible - + def BIND_open_all_game_assets(self): for game_obj in self.app.gw.objects.values(): for art_filename in game_obj.get_all_art(): @@ -1068,145 +1207,156 @@ class InputLord: for art in self.app.gw.hud.arts: self.app.load_art_for_edit(art.filename) self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_all_collision_viz(self): self.app.gw.toggle_all_collision_viz() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_all_bounds_viz(self): self.app.gw.toggle_all_bounds_viz() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_all_origin_viz(self): self.app.gw.toggle_all_origin_viz() self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_collision_on_selected(self): for obj in self.app.gw.selected_objects: if obj.orig_collision_type and obj.collision_type == CT_NONE: obj.enable_collision() - self.ui.message_line.post_line('Collision enabled for %s' % obj.name) + self.ui.message_line.post_line("Collision enabled for %s" % obj.name) elif obj.collision_type != CT_NONE: obj.disable_collision() - self.ui.message_line.post_line('Collision disabled for %s' % obj.name) - + self.ui.message_line.post_line("Collision disabled for %s" % obj.name) + def BIND_toggle_game_edit_ui(self): self.ui.toggle_game_edit_ui() - + # # game mode binds # def accept_normal_game_input(self): - return self.app.game_mode and self.app.gw.player and not self.ui.active_dialog and not self.ui.pulldown.visible - + return ( + self.app.game_mode + and self.app.gw.player + and not self.ui.active_dialog + and not self.ui.pulldown.visible + ) + # TODO: generalize these two somehow def BIND_game_frob(self): if self.accept_normal_game_input(): self.app.gw.player.button_pressed(1) - + def BIND_game_grab(self): if self.accept_normal_game_input(): self.app.gw.player.button_pressed(0) - + def BIND_center_cursor_in_art(self): self.app.cursor.center_in_art() - + def BIND_choose_spawn_object_class(self): if self.app.game_mode and self.app.gw.game_dir: self.ui.edit_list_panel.set_list_operation(LO_SET_SPAWN_CLASS) - + def BIND_duplicate_selected_objects(self): self.app.gw.duplicate_selected_objects() - + def BIND_select_objects(self): if self.app.game_mode and self.app.gw.game_dir: self.ui.edit_list_panel.set_list_operation(LO_SELECT_OBJECTS) - + def BIND_edit_art_for_selected_objects(self): self.app.gw.edit_art_for_selected() - + def BIND_edit_world_properties(self): self.app.gw.deselect_all() self.app.gw.select_object(self.app.gw.properties, force=True) - + def BIND_change_current_room(self): self.ui.edit_list_panel.set_list_operation(LO_SET_ROOM) - + def BIND_change_current_room_to(self, new_room_name): self.app.gw.change_room(new_room_name) self.ui.menu_bar.refresh_active_menu() - + def BIND_add_room(self): self.ui.open_dialog(AddRoomDialog) - + def BIND_remove_current_room(self): self.app.gw.remove_room(self.app.gw.current_room.name) - + def BIND_set_room_objects(self): self.ui.edit_list_panel.set_list_operation(LO_SET_ROOM_OBJECTS) - + def BIND_set_object_rooms(self): self.ui.edit_list_panel.set_list_operation(LO_SET_OBJECT_ROOMS) - + def BIND_toggle_all_rooms_visible(self): self.app.gw.show_all_rooms = not self.app.gw.show_all_rooms self.ui.menu_bar.refresh_active_menu() - + def BIND_toggle_room_camera_changes(self): - self.app.gw.properties.set_object_property('room_camera_changes_enabled', not self.app.gw.room_camera_changes_enabled) + self.app.gw.properties.set_object_property( + "room_camera_changes_enabled", not self.app.gw.room_camera_changes_enabled + ) self.ui.menu_bar.refresh_active_menu() - + def BIND_set_room_camera_marker(self): self.ui.open_dialog(SetRoomCamDialog) self.ui.edit_list_panel.set_list_operation(LO_SET_ROOM_CAMERA) - + def BIND_objects_to_camera(self): cam = self.app.gw.camera for obj in self.app.gw.selected_objects: obj.set_loc(cam.x, cam.y, cam.z) - + def BIND_camera_to_objects(self): if len(self.app.gw.selected_objects) == 0: return obj = self.app.gw.selected_objects[0] self.app.gw.camera.set_loc_from_obj(obj) - + def BIND_add_selected_to_room(self): if not self.app.gw.current_room: return for obj in self.app.gw.selected_objects: self.app.gw.current_room.add_object(obj) - + def BIND_remove_selected_from_room(self): if not self.app.gw.current_room: return for obj in self.app.gw.selected_objects: self.app.gw.current_room.remove_object(obj) - + def BIND_switch_edit_panel_focus(self): self.ui.switch_edit_panel_focus() - + def BIND_switch_edit_panel_focus_reverse(self): self.ui.switch_edit_panel_focus(reverse=True) - + def BIND_set_room_edge_warps(self): # bring up dialog before setting list so list knows about it self.ui.open_dialog(SetRoomEdgeWarpsDialog) self.ui.edit_list_panel.set_list_operation(LO_SET_ROOM_EDGE_WARP) - + def BIND_set_room_bounds_obj(self): self.ui.open_dialog(SetRoomBoundsObjDialog) self.ui.edit_list_panel.set_list_operation(LO_SET_ROOM_EDGE_OBJ) - + def BIND_toggle_list_only_room_objects(self): - self.app.gw.list_only_current_room_objects = not self.app.gw.list_only_current_room_objects + self.app.gw.list_only_current_room_objects = ( + not self.app.gw.list_only_current_room_objects + ) self.ui.menu_bar.refresh_active_menu() - + def BIND_rename_current_room(self): self.ui.open_dialog(RenameRoomDialog) - + def BIND_toggle_debug_objects(self): if not self.app.gw.properties: return - self.app.gw.properties.set_object_property('draw_debug_objects', not self.app.gw.draw_debug_objects) + self.app.gw.properties.set_object_property( + "draw_debug_objects", not self.app.gw.draw_debug_objects + ) self.ui.menu_bar.refresh_active_menu() diff --git a/key_shifts.py b/key_shifts.py index d5c6307..47e0d8d 100644 --- a/key_shifts.py +++ b/key_shifts.py @@ -5,9 +5,27 @@ import sdl2 # MAYBE-TODO: find out if this breaks for non-US english KB layouts SHIFT_MAP = { - '1': '!', '2': '@', '3': '#', '4': '$', '5': '%', '6': '^', '7': '&', '8': '*', - '9': '(', '0': ')', '-': '_', '=': '+', '`': '~', '[': '{', ']': '}', '\\': '|', - ';': ':', "'": '"', ',': '<', '.': '>', '/': '?' + "1": "!", + "2": "@", + "3": "#", + "4": "$", + "5": "%", + "6": "^", + "7": "&", + "8": "*", + "9": "(", + "0": ")", + "-": "_", + "=": "+", + "`": "~", + "[": "{", + "]": "}", + "\\": "|", + ";": ":", + "'": '"', + ",": "<", + ".": ">", + "/": "?", } NUMLOCK_ON_MAP = { @@ -26,7 +44,7 @@ NUMLOCK_ON_MAP = { sdl2.SDLK_KP_PLUS: sdl2.SDLK_PLUS, sdl2.SDLK_KP_MINUS: sdl2.SDLK_MINUS, sdl2.SDLK_KP_PERIOD: sdl2.SDLK_PERIOD, - sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN + sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN, } NUMLOCK_OFF_MAP = { @@ -40,5 +58,5 @@ NUMLOCK_OFF_MAP = { sdl2.SDLK_KP_8: sdl2.SDLK_UP, sdl2.SDLK_KP_9: sdl2.SDLK_PAGEUP, sdl2.SDLK_KP_PERIOD: sdl2.SDLK_DELETE, - sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN + sdl2.SDLK_KP_ENTER: sdl2.SDLK_RETURN, } diff --git a/lab_color.py b/lab_color.py index e84561b..3909ab3 100644 --- a/lab_color.py +++ b/lab_color.py @@ -3,20 +3,21 @@ import math + def rgb_to_xyz(r, g, b): r /= 255.0 g /= 255.0 b /= 255.0 if r > 0.04045: - r = ((r + 0.055) / 1.055)**2.4 + r = ((r + 0.055) / 1.055) ** 2.4 else: r /= 12.92 if g > 0.04045: - g = ((g + 0.055) / 1.055)**2.4 + g = ((g + 0.055) / 1.055) ** 2.4 else: g /= 12.92 if b > 0.04045: - b = ((b + 0.055) / 1.055)**2.4 + b = ((b + 0.055) / 1.055) ** 2.4 else: b /= 12.92 r *= 100 @@ -28,21 +29,22 @@ def rgb_to_xyz(r, g, b): z = r * 0.0193 + g * 0.1192 + b * 0.9505 return x, y, z + def xyz_to_lab(x, y, z): # observer: 2deg, illuminant: D65 x /= 95.047 y /= 100.0 z /= 108.883 if x > 0.008856: - x = x**(1.0/3) + x = x ** (1.0 / 3) else: x = (7.787 * x) + (16.0 / 116) if y > 0.008856: - y = y**(1.0/3) + y = y ** (1.0 / 3) else: y = (7.787 * y) + (16.0 / 116) if z > 0.008856: - z = z**(1.0/3) + z = z ** (1.0 / 3) else: z = (7.787 * z) + (16.0 / 116) l = (116 * y) - 16 @@ -50,13 +52,15 @@ def xyz_to_lab(x, y, z): b = 200 * (y - z) return l, a, b + def rgb_to_lab(r, g, b): x, y, z = rgb_to_xyz(r, g, b) return xyz_to_lab(x, y, z) + def lab_color_diff(l1, a1, b1, l2, a2, b2): "quick n' dirty CIE 1976 color delta" - dl = (l1 - l2)**2 - da = (a1 - a2)**2 - db = (b1 - b2)**2 + dl = (l1 - l2) ** 2 + da = (a1 - a2) ** 2 + db = (b1 - b2) ** 2 return math.sqrt(dl + da + db) diff --git a/palette.py b/palette.py index 31b1bca..477f94f 100644 --- a/palette.py +++ b/palette.py @@ -1,25 +1,31 @@ -import os.path, math, time +import math +import os.path +import time from random import randint + from PIL import Image +from lab_color import lab_color_diff, rgb_to_lab from texture import Texture -from lab_color import rgb_to_lab, lab_color_diff -PALETTE_DIR = 'palettes/' -PALETTE_EXTENSIONS = ['png', 'gif', 'bmp'] +PALETTE_DIR = "palettes/" +PALETTE_EXTENSIONS = ["png", "gif", "bmp"] MAX_COLORS = 1024 + class PaletteLord: - # time in ms between checks for hot reload hot_reload_check_interval = 2 * 1000 - + def __init__(self, app): self.app = app self.last_check = 0 - + def check_hot_reload(self): - if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval: + if ( + self.app.get_elapsed_time() - self.last_check + < self.hot_reload_check_interval + ): return self.last_check = self.app.get_elapsed_time() changed = None @@ -28,18 +34,20 @@ class PaletteLord: changed = palette.filename try: palette.load_image() - self.app.log('PaletteLord: success reloading %s' % palette.filename) + self.app.log("PaletteLord: success reloading %s" % palette.filename) except: - self.app.log('PaletteLord: failed reloading %s' % palette.filename, True) + self.app.log( + "PaletteLord: failed reloading %s" % palette.filename, True + ) class Palette: - def __init__(self, app, src_filename, log): self.init_success = False self.app = app - self.filename = self.app.find_filename_path(src_filename, PALETTE_DIR, - PALETTE_EXTENSIONS) + self.filename = self.app.find_filename_path( + src_filename, PALETTE_DIR, PALETTE_EXTENSIONS + ) if self.filename is None: self.app.log("Couldn't find palette image %s" % src_filename) return @@ -50,15 +58,15 @@ class Palette: self.base_filename = os.path.splitext(os.path.basename(self.filename))[0] if log and not self.app.game_mode: self.app.log("loaded palette '%s' from %s:" % (self.name, self.filename)) - self.app.log(' unique colors found: %s' % int(len(self.colors)-1)) - self.app.log(' darkest color index: %s' % self.darkest_index) - self.app.log(' lightest color index: %s' % self.lightest_index) + self.app.log(" unique colors found: %s" % int(len(self.colors) - 1)) + self.app.log(" darkest color index: %s" % self.darkest_index) + self.app.log(" lightest color index: %s" % self.lightest_index) self.init_success = True - + def load_image(self): "loads palette data from the given bitmap image" src_img = Image.open(self.filename) - src_img = src_img.convert('RGBA') + src_img = src_img.convert("RGBA") width, height = src_img.size # store texture for chooser preview etc self.src_texture = Texture(src_img.tobytes(), width, height) @@ -75,10 +83,10 @@ class Palette: if len(self.colors) >= MAX_COLORS: break color = src_img.getpixel((x, y)) - if not color in self.colors: + if color not in self.colors: self.colors.append(color) # is this lightest/darkest unique color so far? save index - luminosity = color[0]*0.21 + color[1]*0.72 + color[2]*0.07 + luminosity = color[0] * 0.21 + color[1] * 0.72 + color[2] * 0.07 if luminosity < darkest: darkest = luminosity self.darkest_index = len(self.colors) - 1 @@ -86,27 +94,27 @@ class Palette: lightest = luminosity self.lightest_index = len(self.colors) - 1 # create new 1D image with unique colors - img = Image.new('RGBA', (MAX_COLORS, 1), (0, 0, 0, 0)) + img = Image.new("RGBA", (MAX_COLORS, 1), (0, 0, 0, 0)) x = 0 for color in self.colors: img.putpixel((x, 0), color) x += 1 # debug: save out generated palette texture - #img.save('palette.png') + # img.save('palette.png') self.texture = Texture(img.tobytes(), MAX_COLORS, 1) - + def has_updated(self): "return True if source image file has changed since last check" changed = os.path.getmtime(self.filename) > self.last_image_change if changed: self.last_image_change = time.time() return changed - + def generate_image(self): width = min(16, len(self.colors) - 1) height = math.floor((len(self.colors) - 1) / width) # new PIL image, blank (0 alpha) pixels - img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) # set each pixel from color list (minus first, transparent color) color_index = 1 for y in range(height): @@ -116,44 +124,48 @@ class Palette: img.putpixel((x, y), self.colors[color_index]) color_index += 1 return img - + def export_as_image(self): img = self.generate_image() block_size = 8 # scale up width, height = img.size - img = img.resize((width * block_size, height * block_size), - resample=Image.NEAREST) + img = img.resize( + (width * block_size, height * block_size), resample=Image.NEAREST + ) # write to file - img_filename = self.app.documents_dir + PALETTE_DIR + self.name + '.png' + img_filename = self.app.documents_dir + PALETTE_DIR + self.name + ".png" img.save(img_filename) - + def all_colors_opaque(self): "returns True if we have any non-opaque (<1 alpha) colors" for color in self.colors[1:]: if color[3] < 255: return False return True - + def get_random_non_palette_color(self): "returns random color not in this palette, eg for 8-bit transparency" + def rand_byte(): return randint(0, 255) + # assume full alpha r, g, b, a = rand_byte(), rand_byte(), rand_byte(), 255 while (r, g, b, a) in self.colors: r, g, b = rand_byte(), rand_byte(), rand_byte() return r, g, b, a - - def get_palettized_image(self, src_img, transparent_color=(0, 0, 0), - force_no_transparency=False): + + def get_palettized_image( + self, src_img, transparent_color=(0, 0, 0), force_no_transparency=False + ): "returns a copy of source image quantized to this palette" - pal_img = Image.new('P', (1, 1)) + pal_img = Image.new("P", (1, 1)) # source must be in RGB (no alpha) format - out_img = src_img.convert('RGB') + out_img = src_img.convert("RGB") # Image.putpalette needs a flat tuple :/ colors = [] - for i,color in enumerate(self.colors): + for i, color in enumerate(self.colors): # ignore alpha for palettized image output for channel in color[:-1]: colors.append(channel) @@ -165,12 +177,11 @@ class Palette: for i in range(3): colors.append(0) # palette for PIL must be exactly 256 colors - colors = colors[:256*3] + colors = colors[: 256 * 3] pal_img.putpalette(tuple(colors)) return out_img.quantize(palette=pal_img) - - def are_colors_similar(self, color_index_a, palette_b, color_index_b, - tolerance=50): + + def are_colors_similar(self, color_index_a, palette_b, color_index_b, tolerance=50): """ returns True if color index A is similar to color index B from another palette. @@ -181,35 +192,34 @@ class Palette: g_diff = abs(color_a[1] - color_b[1]) b_diff = abs(color_a[2] - color_b[2]) return (r_diff + g_diff + b_diff) <= tolerance - + def get_closest_color_index(self, r, g, b): "returns index of closest color in this palette to given color (kinda slow?)" closest_diff = 99999999999 closest_diff_index = -1 - for i,color in enumerate(self.colors): + for i, color in enumerate(self.colors): l1, a1, b1 = rgb_to_lab(r, g, b) l2, a2, b2 = rgb_to_lab(*color[:3]) diff = lab_color_diff(l1, a1, b1, l2, a2, b2) if diff < closest_diff: closest_diff = diff closest_diff_index = i - #print('%s is closest to input color %s' % (self.colors[closest_diff_index], (r, g, b))) + # print('%s is closest to input color %s' % (self.colors[closest_diff_index], (r, g, b))) return closest_diff_index - + def get_random_color_index(self): # exclude transparent first index return randint(1, len(self.colors)) class PaletteFromList(Palette): - "palette created from list of 3/4-tuple base-255 colors instead of image" - + def __init__(self, app, src_color_list, log): self.init_success = False self.app = app # generate a unique non-user-facing palette name - name = 'PaletteFromList_%s' % time.time() + name = "PaletteFromList_%s" % time.time() self.filename = self.name = self.base_filename = name colors = [] for color in src_color_list: @@ -222,7 +232,7 @@ class PaletteFromList(Palette): lightest = 0 darkest = 255 * 3 + 1 for color in self.colors: - luminosity = color[0]*0.21 + color[1]*0.72 + color[2]*0.07 + luminosity = color[0] * 0.21 + color[1] * 0.72 + color[2] * 0.07 if luminosity < darkest: darkest = luminosity self.darkest_index = len(self.colors) - 1 @@ -230,7 +240,7 @@ class PaletteFromList(Palette): lightest = luminosity self.lightest_index = len(self.colors) - 1 # create texture - img = Image.new('RGBA', (MAX_COLORS, 1), (0, 0, 0, 0)) + img = Image.new("RGBA", (MAX_COLORS, 1), (0, 0, 0, 0)) x = 0 for color in self.colors: img.putpixel((x, 0), color) @@ -238,17 +248,16 @@ class PaletteFromList(Palette): self.texture = Texture(img.tobytes(), MAX_COLORS, 1) if log and not self.app.game_mode: self.app.log("generated new palette '%s'" % (self.name)) - self.app.log(' unique colors: %s' % int(len(self.colors)-1)) - self.app.log(' darkest color index: %s' % self.darkest_index) - self.app.log(' lightest color index: %s' % self.lightest_index) - + self.app.log(" unique colors: %s" % int(len(self.colors) - 1)) + self.app.log(" darkest color index: %s" % self.darkest_index) + self.app.log(" lightest color index: %s" % self.lightest_index) + def has_updated(self): "No bitmap source for this type of palette, so no hot-reload" return False class PaletteFromFile(Palette): - def __init__(self, app, src_filename, palette_filename, colors=MAX_COLORS): self.init_success = False src_filename = app.find_filename_path(src_filename) @@ -258,8 +267,10 @@ class PaletteFromFile(Palette): # dither source image, re-save it, use that as the source for a palette src_img = Image.open(src_filename) # method: - src_img = src_img.convert('P', None, Image.FLOYDSTEINBERG, Image.ADAPTIVE, colors) - src_img = src_img.convert('RGBA') + src_img = src_img.convert( + "P", None, Image.FLOYDSTEINBERG, Image.ADAPTIVE, colors + ) + src_img = src_img.convert("RGBA") # write converted source image with new filename # snip path & extension if it has em palette_filename = os.path.basename(palette_filename) @@ -267,13 +278,15 @@ class PaletteFromFile(Palette): # get most appropriate path for palette image palette_path = app.get_dirnames(PALETTE_DIR, False)[0] # if new filename exists, add a number to avoid overwriting - if os.path.exists(palette_path + palette_filename + '.png'): + if os.path.exists(palette_path + palette_filename + ".png"): i = 0 - while os.path.exists('%s%s%s.png' % (palette_path, palette_filename, str(i))): + while os.path.exists( + "%s%s%s.png" % (palette_path, palette_filename, str(i)) + ): i += 1 palette_filename += str(i) # (re-)add path and PNG extension - palette_filename = palette_path + palette_filename + '.png' + palette_filename = palette_path + palette_filename + ".png" src_img.save(palette_filename) # create the actual palette and export it as an image Palette.__init__(self, app, palette_filename, True) diff --git a/playscii.py b/playscii.py index b7b7c37..3c704e3 100755 --- a/playscii.py +++ b/playscii.py @@ -1,100 +1,122 @@ #!/usr/bin/env python3 -# coding=utf-8 -from __future__ import print_function -import sys, os.path +import os.path +import sys if sys.version_info.major < 3: - print('Python 3 is required to run Playscii.', file=sys.stderr) + print("Python 3 is required to run Playscii.", file=sys.stderr) sys.exit(1) import platform -if platform.system() == 'Windows' or platform.system() == 'Darwin': + +if platform.system() == "Windows" or platform.system() == "Darwin": import os + # set env variable so pysdl2 can find sdl2.dll - os.environ['PYSDL2_DLL_PATH'] = '.' - sys.path += ['.'] + os.environ["PYSDL2_DLL_PATH"] = "." + sys.path += ["."] # fix the working directory when running in a mac app -if platform.system() == 'Darwin' and hasattr(sys, 'frozen'): +if platform.system() == "Darwin" and hasattr(sys, "frozen"): os.chdir(os.path.abspath(os.path.dirname(sys.executable))) # app imports -import ctypes, time, hashlib, importlib, traceback +import ctypes +import hashlib +import importlib +import time +import traceback import webbrowser + +import appdirs +import numpy # just for version checks +import OpenGL +import PIL import sdl2 import sdl2.ext -import appdirs -import PIL, OpenGL, numpy # just for version checks -# DEBUG: GL context checking, must be set before other imports and calls -#OpenGL.CONTEXT_CHECKING = True -from sdl2 import video, sdlmixer from OpenGL import GL -from PIL import Image from packaging import version +from PIL import Image + +# DEBUG: GL context checking, must be set before other imports and calls +# OpenGL.CONTEXT_CHECKING = True +from sdl2 import sdlmixer, video + # work around a name being deprecated in different versions of PIL -if version.parse(Image.__version__) > version.parse('10.0.0'): +if version.parse(Image.__version__) > version.parse("10.0.0"): Image.ANTIALIAS = Image.LANCZOS # cache whether pdoc is available for help menu item pdoc_available = False try: - import pdoc pdoc_available = True -except: pass +except: + pass # submodules - set here so cfg file can modify them all easily -from audio import AudioLord -from shader import ShaderLord -from camera import Camera -from charset import CharacterSet, CharacterSetLord, CHARSET_DIR -from palette import Palette, PaletteLord, PALETTE_DIR -from art import Art, ArtFromDisk, DEFAULT_CHARSET, DEFAULT_PALETTE, DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_ART_FILENAME -from art_import import ArtImporter +from art import ( + ART_DIR, + ART_FILE_EXTENSION, + ART_SCRIPT_DIR, + DEFAULT_ART_FILENAME, + DEFAULT_CHARSET, + DEFAULT_HEIGHT, + DEFAULT_PALETTE, + DEFAULT_WIDTH, + Art, + ArtFromDisk, +) from art_export import ArtExporter -from renderable import TileRenderable, OnionTileRenderable -from renderable_line import DebugLineRenderable -from renderable_sprite import UIBGTextureRenderable, SpriteRenderable -from framebuffer import Framebuffer -from art import ART_DIR, ART_FILE_EXTENSION, ART_SCRIPT_DIR -from ui import UI, OIS_WIDTH +from art_import import ArtImporter +from audio import AudioLord +from camera import Camera +from charset import CHARSET_DIR, CharacterSet, CharacterSetLord from cursor import Cursor +from framebuffer import Framebuffer +from game_world import TOP_GAME_DIR, GameWorld from grid import ArtGrid from input_handler import InputLord -from ui_file_chooser_dialog import THUMBNAIL_CACHE_DIR +from palette import PALETTE_DIR, Palette, PaletteLord +from renderable import OnionTileRenderable, TileRenderable + # some classes are imported only so the cfg file can modify their defaults -from renderable_line import LineRenderable -from ui_swatch import CharacterSetSwatch -from ui_element import UIRenderable, FPSCounterUI, DebugTextUI -from ui_menu_pulldown import PulldownMenu -from ui_dialog import UIDialog -from ui_chooser_dialog import ScrollArrowButton, ChooserDialog -from image_convert import ImageConverter -from game_world import GameWorld, TOP_GAME_DIR -from game_object import GameObject -from shader import Shader +from renderable_line import DebugLineRenderable +from renderable_sprite import SpriteRenderable, UIBGTextureRenderable +from shader import ShaderLord +from ui import OIS_WIDTH, UI +from ui_file_chooser_dialog import THUMBNAIL_CACHE_DIR -APP_NAME = 'Playscii' -VERSION_FILENAME = 'version' +APP_NAME = "Playscii" +VERSION_FILENAME = "version" -CONFIG_FILENAME = 'playscii.cfg' -CONFIG_TEMPLATE_FILENAME = CONFIG_FILENAME + '.default' -LOG_FILENAME = 'console.log' -SESSION_FILENAME = 'playscii.session' -LOGO_FILENAME = 'ui/logo.png' -SCREENSHOT_DIR = 'screenshots/' -FORMATS_DIR = 'formats/' -AUTOPLAY_GAME_FILENAME = 'autoplay_this_game' +CONFIG_FILENAME = "playscii.cfg" +CONFIG_TEMPLATE_FILENAME = CONFIG_FILENAME + ".default" +LOG_FILENAME = "console.log" +SESSION_FILENAME = "playscii.session" +LOGO_FILENAME = "ui/logo.png" +SCREENSHOT_DIR = "screenshots/" +FORMATS_DIR = "formats/" +AUTOPLAY_GAME_FILENAME = "autoplay_this_game" -WEBSITE_URL = 'https://jplebreton.com/playscii' -WEBSITE_HELP_URL = 'docs/html/howto_main.html' -AUTOGEN_DOCS_PATH = 'docs/html/generated/' -AUTOGEN_DOC_MODULES = ['game_object', 'game_world', 'game_room', 'collision', - 'game_util_objects', 'art', 'renderable', 'vector', - 'art_import', 'art_export'] -AUTOGEN_DOC_TOC_PAGE = 'pdoc_toc.html' +WEBSITE_URL = "https://jplebreton.com/playscii" +WEBSITE_HELP_URL = "docs/html/howto_main.html" +AUTOGEN_DOCS_PATH = "docs/html/generated/" +AUTOGEN_DOC_MODULES = [ + "game_object", + "game_world", + "game_room", + "collision", + "game_util_objects", + "art", + "renderable", + "vector", + "art_import", + "art_export", +] +AUTOGEN_DOC_TOC_PAGE = "pdoc_toc.html" MAX_ONION_FRAMES = 3 + class Application: # default window dimensions, may be updated during screen res detection window_width, window_height = 1280, 720 @@ -127,25 +149,36 @@ class Application: show_dev_log = False # in art mode, show layers marked invisible to game mode show_hidden_layers = False - welcome_message = 'Welcome to Playscii! Press SPACE to select characters and colors to paint.' + welcome_message = ( + "Welcome to Playscii! Press SPACE to select characters and colors to paint." + ) compat_fail_message = "your hardware doesn't appear to meet Playscii's requirements! Sorry ;________;" - game_mode_message = 'Game Mode active, press %s to return to Art Mode.' - img_convert_message = 'converting bitmap image: %s' + game_mode_message = "Game Mode active, press %s to return to Art Mode." + img_convert_message = "converting bitmap image: %s" # can_edit: if False, user can't use art or edit functionality can_edit = True # these values should be written to cfg files on exit # key = module path, value = [member object (blank if self), var name] persistent_setting_names = { - 'UI.popup_hold_to_show': ['ui', 'popup_hold_to_show'], - 'Framebuffer.start_crt_enabled': ['fb', 'crt'], - 'Application.show_bg_texture': ['', 'show_bg_texture'], - 'ArtGrid.visible': ['art_grid', 'visible'] + "UI.popup_hold_to_show": ["ui", "popup_hold_to_show"], + "Framebuffer.start_crt_enabled": ["fb", "crt"], + "Application.show_bg_texture": ["", "show_bg_texture"], + "ArtGrid.visible": ["art_grid", "visible"], } # characters that can't appear in filenames (any OS; Windows is least permissive) - forbidden_filename_chars = ['/', '\\', '*', ':'] - - def __init__(self, config_dir, documents_dir, cache_dir, logger, - art_filename, game_dir_to_load, state_to_load, autoplay_game): + forbidden_filename_chars = ["/", "\\", "*", ":"] + + def __init__( + self, + config_dir, + documents_dir, + cache_dir, + logger, + art_filename, + game_dir_to_load, + state_to_load, + autoplay_game, + ): self.init_success = False self.config_dir = config_dir # keep playscii.cfg lines in case we want to add some @@ -180,18 +213,18 @@ class Application: self.ui, self.al = None, None # on linux, prefer wayland, via SDL 2.0.22's SDL_HINT_VIDEODRIVER # http://wiki.libsdl.org/SDL_SetHint - if platform.system() == 'Linux': + if platform.system() == "Linux": # but if we're running x11, wayland won't be available # and we'll get an SDLError - so handle the exception. # seems like we should be able to ask for, eg # sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b'wayland, x11'), # though. TODO research this further, see how other progs do it. try: - sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b'wayland') + sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b"wayland") sdl2.ext.init() except: try: - sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b'x11') + sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b"x11") sdl2.ext.init() except: sdl2.ext.init() @@ -204,73 +237,91 @@ class Application: self.window_height = min(self.window_height, max_height) # TODO: SDL_WINDOW_ALLOW_HIGHDPI doesn't seem to work right, # determine whether we're using it wrong or it's broken - flags = sdl2.SDL_WINDOW_OPENGL | sdl2.SDL_WINDOW_RESIZABLE# | sdl2.SDL_WINDOW_ALLOW_HIGHDPI + flags = ( + sdl2.SDL_WINDOW_OPENGL | sdl2.SDL_WINDOW_RESIZABLE + ) # | sdl2.SDL_WINDOW_ALLOW_HIGHDPI if self.fullscreen: flags = flags | sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP - self.window = sdl2.SDL_CreateWindow(bytes(APP_NAME, 'utf-8'), - winpos, winpos, - self.window_width, self.window_height, - flags) - sdl2.SDL_SetWindowMinimumSize(self.window, self.min_window_width, - self.min_window_height) + self.window = sdl2.SDL_CreateWindow( + bytes(APP_NAME, "utf-8"), + winpos, + winpos, + self.window_width, + self.window_height, + flags, + ) + sdl2.SDL_SetWindowMinimumSize( + self.window, self.min_window_width, self.min_window_height + ) # force GL2.1 'core' before creating context video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_MAJOR_VERSION, 2) video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_MINOR_VERSION, 1) - video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_PROFILE_MASK, - video.SDL_GL_CONTEXT_PROFILE_CORE) + video.SDL_GL_SetAttribute( + video.SDL_GL_CONTEXT_PROFILE_MASK, video.SDL_GL_CONTEXT_PROFILE_CORE + ) self.context = sdl2.SDL_GL_CreateContext(self.window) # if creating a core profile context fails, try GL ES 2.0 if not self.context: video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_MAJOR_VERSION, 2) video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_MINOR_VERSION, 0) - video.SDL_GL_SetAttribute(video.SDL_GL_CONTEXT_PROFILE_MASK, - video.SDL_GL_CONTEXT_PROFILE_ES) + video.SDL_GL_SetAttribute( + video.SDL_GL_CONTEXT_PROFILE_MASK, video.SDL_GL_CONTEXT_PROFILE_ES + ) self.context = sdl2.SDL_GL_CreateContext(self.window) # save ES status for later use by eg Shaders self.context_es = True else: self.context_es = False - self.log('Detecting hardware...') + self.log("Detecting hardware...") cpu = platform.processor() or platform.machine() - self.log(' CPU: %s' % (cpu if cpu != '' else "[couldn't detect CPU]")) + self.log(" CPU: %s" % (cpu if cpu != "" else "[couldn't detect CPU]")) # report GL vendor, version, GLSL version etc - try: gpu_vendor = GL.glGetString(GL.GL_VENDOR).decode('utf-8') - except: gpu_vendor = "[couldn't detect vendor]" - try: gpu_renderer = GL.glGetString(GL.GL_RENDERER).decode('utf-8') - except: gpu_renderer = "[couldn't detect renderer]" - self.log(' GPU: %s - %s' % (gpu_vendor, gpu_renderer)) + try: + gpu_vendor = GL.glGetString(GL.GL_VENDOR).decode("utf-8") + except: + gpu_vendor = "[couldn't detect vendor]" + try: + gpu_renderer = GL.glGetString(GL.GL_RENDERER).decode("utf-8") + except: + gpu_renderer = "[couldn't detect renderer]" + self.log(" GPU: %s - %s" % (gpu_vendor, gpu_renderer)) try: # try single-argument GL2.0 version first gl_ver = GL.glGetString(GL.GL_VERSION) if not gl_ver: gl_ver = GL.glGetString(GL.GL_VERSION, ctypes.c_int(0)) - gl_ver = gl_ver.decode('utf-8') + gl_ver = gl_ver.decode("utf-8") except: gl_ver = "[couldn't detect GL version]" - self.log(' OpenGL detected: %s' % gl_ver) + self.log(" OpenGL detected: %s" % gl_ver) # GL 1.1 doesn't even habla shaders, quit if we fail GLSL version check try: glsl_ver = GL.glGetString(GL.GL_SHADING_LANGUAGE_VERSION) if not glsl_ver: - glsl_ver = GL.glGetString(GL.GL_SHADING_LANGUAGE_VERSION, ctypes.c_int(0)) + glsl_ver = GL.glGetString( + GL.GL_SHADING_LANGUAGE_VERSION, ctypes.c_int(0) + ) except: - self.log('GLSL support not detected, ' + self.compat_fail_message) + self.log("GLSL support not detected, " + self.compat_fail_message) self.should_quit = True return - glsl_ver = glsl_ver.decode('utf-8') if glsl_ver != None else None - self.log(' GLSL detected: %s' % glsl_ver or '[unknown]') + glsl_ver = glsl_ver.decode("utf-8") if glsl_ver != None else None + self.log(" GLSL detected: %s" % glsl_ver or "[unknown]") # verify that we got at least a 2.1 context majorv, minorv = ctypes.c_int(0), ctypes.c_int(0) video.SDL_GL_GetAttribute(video.SDL_GL_CONTEXT_MAJOR_VERSION, majorv) video.SDL_GL_GetAttribute(video.SDL_GL_CONTEXT_MINOR_VERSION, minorv) context_version = majorv.value + (minorv.value * 0.1) self.use_vao = bool(GL.glGenVertexArrays) - self.log(' Vertex Array Object support %sfound.' % ['NOT ', ''][self.use_vao]) + self.log(" Vertex Array Object support %sfound." % ["NOT ", ""][self.use_vao]) if not self.context: self.log("No OpenGL context found!") # enforce GL version requirement - if not self.context or context_version < 2.1 or gl_ver.startswith('2.0'): - self.log("Couldn't create a compatible OpenGL context, " + self.compat_fail_message) + if not self.context or context_version < 2.1 or gl_ver.startswith("2.0"): + self.log( + "Couldn't create a compatible OpenGL context, " + + self.compat_fail_message + ) if not self.run_if_opengl_incompatible: self.should_quit = True return @@ -289,32 +340,42 @@ class Application: mts = ctypes.c_int(0) GL.glGetIntegerv(GL.GL_MAX_TEXTURE_SIZE, mts) self.max_texture_size = mts.value - self.log(' Maximum supported texture size: %s x %s' % (self.max_texture_size, self.max_texture_size)) - self.log(' Detected screen resolution: %.0f x %.0f, window: %s x %s' % (screen_width, screen_height, self.window_width, self.window_height)) - self.log('Detecting software environment...') - self.log(' OS: %s' % platform.platform()) - py_version = ' '.join(sys.version.split('\n')) + self.log( + " Maximum supported texture size: %s x %s" + % (self.max_texture_size, self.max_texture_size) + ) + self.log( + " Detected screen resolution: %.0f x %.0f, window: %s x %s" + % (screen_width, screen_height, self.window_width, self.window_height) + ) + self.log("Detecting software environment...") + self.log(" OS: %s" % platform.platform()) + py_version = " ".join(sys.version.split("\n")) # report 32 vs 64 bit as it's not clear from sys.version or OS bitness = platform.architecture()[0] # on linux, report whether we're running x11 or wayland - if platform.system() == 'Linux': - driver = sdl2.SDL_GetCurrentVideoDriver().decode('utf-8') + if platform.system() == "Linux": + driver = sdl2.SDL_GetCurrentVideoDriver().decode("utf-8") self.log(' Linux SDL2 "video driver": %s' % driver) - self.log(' Python: %s (%s)' % (py_version, bitness)) - module_versions = 'PySDL2 %s, ' % sdl2.__version__ - module_versions += 'numpy %s, ' % numpy.__version__ - module_versions += 'PyOpenGL %s, ' % OpenGL.__version__ - module_versions += 'appdirs %s, ' % appdirs.__version__ - module_versions += 'PIL %s' % PIL.__version__ - self.log(' Modules: %s' % module_versions) - sdl_version = '%s.%s.%s ' % (sdl2.version.SDL_MAJOR_VERSION, - sdl2.version.SDL_MINOR_VERSION, - sdl2.version.SDL_PATCHLEVEL) - sdl_version += sdl2.version.SDL_GetRevision().decode('utf-8') - sdl_version += ', SDLmixer: %s.%s.%s' % (sdlmixer.SDL_MIXER_MAJOR_VERSION, - sdlmixer.SDL_MIXER_MINOR_VERSION, - sdlmixer.SDL_MIXER_PATCHLEVEL) - self.log(' SDL: %s' % sdl_version) + self.log(" Python: %s (%s)" % (py_version, bitness)) + module_versions = "PySDL2 %s, " % sdl2.__version__ + module_versions += "numpy %s, " % numpy.__version__ + module_versions += "PyOpenGL %s, " % OpenGL.__version__ + module_versions += "appdirs %s, " % appdirs.__version__ + module_versions += "PIL %s" % PIL.__version__ + self.log(" Modules: %s" % module_versions) + sdl_version = "%s.%s.%s " % ( + sdl2.version.SDL_MAJOR_VERSION, + sdl2.version.SDL_MINOR_VERSION, + sdl2.version.SDL_PATCHLEVEL, + ) + sdl_version += sdl2.version.SDL_GetRevision().decode("utf-8") + sdl_version += ", SDLmixer: %s.%s.%s" % ( + sdlmixer.SDL_MIXER_MAJOR_VERSION, + sdlmixer.SDL_MIXER_MINOR_VERSION, + sdlmixer.SDL_MIXER_PATCHLEVEL, + ) + self.log(" SDL: %s" % sdl_version) # draw black screen while doing other init GL.glClearColor(0.0, 0.0, 0.0, 1.0) GL.glClear(GL.GL_COLOR_BUFFER_BIT) @@ -396,7 +457,7 @@ class Application: self.cursor.pre_first_update() self.pdoc_available = pdoc_available self.init_success = True - self.log('Init done.') + self.log("Init done.") if self.can_edit: self.restore_session() # if art file was given in arguments, set it active @@ -409,7 +470,7 @@ class Application: else: self.gw.load_game_state() else: - #self.ui.message_line.post_line(self.welcome_message, 10) + # self.ui.message_line.post_line(self.welcome_message, 10) pass # if "autoplay_this_game" used and game is valid, lock out edit mode if not self.can_edit: @@ -418,7 +479,7 @@ class Application: self.gw.draw_debug_objects = False elif self.gw.game_dir and self.always_launch_art_mode: self.exit_game_mode() - + def get_desktop_resolution(self): winpos = sdl2.SDL_WINDOWPOS_UNDEFINED # use the more direct way of getting desktop resolution @@ -428,19 +489,24 @@ class Application: return desktop.w, desktop.h # this method seems to have broken recently (2022-06) on our Linux + # Nvidia + X11 + SDL2.20 setup; default it off but keep it around... - test_window = sdl2.SDL_CreateWindow(bytes(APP_NAME, 'utf-8'), - winpos, winpos, - 128, 128, - sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP) + test_window = sdl2.SDL_CreateWindow( + bytes(APP_NAME, "utf-8"), + winpos, + winpos, + 128, + 128, + sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP, + ) sdl2.SDL_HideWindow(test_window) screen_width, screen_height = ctypes.c_int(0), ctypes.c_int(0) - sdl2.SDL_GetWindowSize(test_window, ctypes.pointer(screen_width), - ctypes.pointer(screen_height)) + sdl2.SDL_GetWindowSize( + test_window, ctypes.pointer(screen_width), ctypes.pointer(screen_height) + ) screen_width = screen_width.value screen_height = screen_height.value sdl2.SDL_DestroyWindow(test_window) return screen_width, screen_height - + def is_mouse_inside_window(self): "returns True if mouse is inside application window" wx, wy = ctypes.c_int(0), ctypes.c_int(0) @@ -450,75 +516,79 @@ class Application: # "global" mouse state = whole-desktop mouse coordinates sdl2.mouse.SDL_GetGlobalMouseState(mx, my) mx, my = int(mx.value), int(my.value) - return wx <= mx <= wx + self.window_width and \ - wy <= my <= wy + self.window_height - + return ( + wx <= mx <= wx + self.window_width and wy <= my <= wy + self.window_height + ) + def set_icon(self): # TODO: this doesn't seem to work in Ubuntu, what am i missing? - img = Image.open(LOGO_FILENAME).convert('RGBA') + img = Image.open(LOGO_FILENAME).convert("RGBA") # does icon need to be a specific size? img = img.resize((32, 32), Image.Resampling.LANCZOS) w, h = img.size depth, pitch = 32, w * 4 - #SDL_CreateRGBSurfaceFrom((pixels, width, height, depth, pitch, Rmask, Gmask, Bmask, Amask) - #mask = (0x0f00, 0x00f0, 0x000f, 0xf000) - mask = (0x000000ff, 0x0000ff00, 0x00ff0000, 0xff000000) - icon_surf = sdl2.SDL_CreateRGBSurfaceFrom(img.tobytes(), w, h, depth, pitch, *mask) + # SDL_CreateRGBSurfaceFrom((pixels, width, height, depth, pitch, Rmask, Gmask, Bmask, Amask) + # mask = (0x0f00, 0x00f0, 0x000f, 0xf000) + mask = (0x000000FF, 0x0000FF00, 0x00FF0000, 0xFF000000) + icon_surf = sdl2.SDL_CreateRGBSurfaceFrom( + img.tobytes(), w, h, depth, pitch, *mask + ) # SDL_SetWindowIcon(self.window, SDL_Surface* icon) sdl2.SDL_SetWindowIcon(self.window, icon_surf) sdl2.SDL_FreeSurface(icon_surf) - + def log(self, new_line, error=False): "write to log file, stdout, and in-app console log" self.logger.log(new_line) if self.ui and self.can_edit: self.ui.message_line.post_line(new_line, hold_time=None, error=error) - + def dev_log(self, new_line): if self.show_dev_log: self.log(new_line) - + def log_import_exception(self, e, module_name): """ Logs a readable version of stack trace of given exception encountered importing given module name. """ - for line in traceback.format_exc().split('\n'): + for line in traceback.format_exc().split("\n"): # ignore the importlib parts of the call stack, # not useful and always the same - if line and not 'importlib' in line and \ - not 'in _import_all' in line and \ - not '_bootstrap._gcd_import' in line: + if ( + line + and "importlib" not in line + and "in _import_all" not in line + and "_bootstrap._gcd_import" not in line + ): self.log(line.rstrip()) - s = 'Error importing module %s! See console.' % module_name + s = "Error importing module %s! See console." % module_name if self.ui: self.ui.message_line.post_line(s, 10, True) - - def new_art(self, filename, width=None, height=None, - charset=None, palette=None): + + def new_art(self, filename, width=None, height=None, charset=None, palette=None): width, height = width or DEFAULT_WIDTH, height or DEFAULT_HEIGHT - filename = filename if filename and filename != '' else DEFAULT_ART_FILENAME + filename = filename if filename and filename != "" else DEFAULT_ART_FILENAME charset = self.load_charset(charset or DEFAULT_CHARSET) palette = self.load_palette(palette or DEFAULT_PALETTE) art = Art(filename, self, charset, palette, width, height) art.set_filename(filename) art.time_loaded = time.time() return art - + def load_art(self, filename, autocreate=True): """ load given file from disk; by default autocreate new file if it couldn't be found """ - valid_filename = self.find_filename_path(filename, ART_DIR, - ART_FILE_EXTENSION) + valid_filename = self.find_filename_path(filename, ART_DIR, ART_FILE_EXTENSION) art = None if not valid_filename: if autocreate: - self.log('Creating new art %s' % filename) + self.log("Creating new art %s" % filename) return self.new_art(filename) else: - #self.log("Couldn't find art %s" % filename) + # self.log("Couldn't find art %s" % filename) return None # if already loaded, return that for a in self.art_loaded_for_edit + self.gw.art_loaded: @@ -531,12 +601,12 @@ class Application: # remember time loaded for UI list sorting art.time_loaded = time.time() return art - + def new_art_for_edit(self, filename, width=None, height=None): "Create a new Art and set it editable in Art Mode." art = self.new_art(filename, width, height) self.set_new_art_for_edit(art) - + def set_new_art_for_edit(self, art): "Makes given Art editable in Art Mode UI." self.art_loaded_for_edit.insert(0, art) @@ -545,21 +615,21 @@ class Application: self.ui.set_active_art(art) self.camera.toggle_zoom_extents() art.set_unsaved_changes(True) - + def load_art_for_edit(self, filename): art = self.load_art(filename) if art in self.art_loaded_for_edit: self.ui.set_active_art(art) - #self.ui.message_line.post_line('Art file %s already loaded' % filename) + # self.ui.message_line.post_line('Art file %s already loaded' % filename) return self.art_loaded_for_edit.insert(0, art) renderable = TileRenderable(self, art) self.edit_renderables.insert(0, renderable) if self.ui: self.ui.set_active_art(art) - + def close_art(self, art): - if not art in self.art_loaded_for_edit: + if art not in self.art_loaded_for_edit: return self.art_loaded_for_edit.remove(art) for r in art.renderables: @@ -567,20 +637,20 @@ class Application: self.edit_renderables.remove(r) if art is self.ui.active_art: self.ui.active_art = None - self.log('Unloaded %s' % art.filename) + self.log("Unloaded %s" % art.filename) if len(self.art_loaded_for_edit) > 0: self.ui.set_active_art(self.art_loaded_for_edit[0]) self.update_window_title() - + def revert_active_art(self): filename = self.ui.active_art.filename self.close_art(self.ui.active_art) self.load_art_for_edit(filename) - + def get_file_hash(self, filename): - f_data = open(filename, 'rb').read() + f_data = open(filename, "rb").read() return hashlib.md5(f_data).hexdigest() - + def get_dirnames(self, subdir=None, include_base=True): "returns list of suitable directory names across app and user dirs" dirnames = [] @@ -595,7 +665,7 @@ class Application: if subdir is not None: dirnames.append(subdir) if include_base: - dirnames.append('') + dirnames.append("") # add duplicate set of dirs in user documents path doc_dirs = [] for dirname in dirnames: @@ -608,10 +678,10 @@ class Application: doc_dirs.append(doc_dir) # check in user document dirs first return doc_dirs + dirnames - + def find_filename_path(self, filename, subdir=None, extensions=None): "returns a valid path for given file, extension, subdir (art/ etc)" - if not filename or filename == '': + if not filename or filename == "": return None dirnames = self.get_dirnames(subdir) # build list of filenames from each dir, first w/ extension then w/o @@ -619,46 +689,46 @@ class Application: # extensions: accept list or single item, # list with one empty string if None passed if extensions is None or len(extensions) == 0: - extensions = [''] - elif not type(extensions) is list: + extensions = [""] + elif type(extensions) is not list: extensions = [extensions] for dirname in dirnames: for ext in extensions: - f = '%s%s' % (dirname, filename) + f = "%s%s" % (dirname, filename) # filename passed in might already have intended extension, # eg from a directory listing - if ext and ext != '' and not filename.endswith(ext): - f += '.' + ext + if ext and ext != "" and not filename.endswith(ext): + f += "." + ext filenames.append(f) # return first one we find for f in filenames: if f is not None and os.path.exists(f) and os.path.isfile(f): return f return None - + def get_converter_classes(self, base_class): "return a list of converter classes for importer/exporter selection" classes = [] # on first load, documents dir may not be in import path - if not self.documents_dir in sys.path: + if self.documents_dir not in sys.path: sys.path += [self.documents_dir] # read from application (builtins) and user documents dirs files = os.listdir(FORMATS_DIR) files += os.listdir(self.documents_dir + FORMATS_DIR) for filename in files: basename, ext = os.path.splitext(filename) - if not ext.lower() == '.py': + if not ext.lower() == ".py": continue try: if basename in self.converter_modules: m = importlib.reload(self.converter_modules[basename]) else: - m = importlib.import_module('formats.%s' % basename) + m = importlib.import_module("formats.%s" % basename) self.converter_modules[basename] = m except Exception as e: self.log_import_exception(e, basename) - for k,v in m.__dict__.items(): - if not type(v) is type: + for k, v in m.__dict__.items(): + if type(v) is not type: continue # don't add duplicates # (can happen if eg one importer extends another) @@ -667,15 +737,15 @@ class Application: if issubclass(v, base_class) and v is not base_class: classes.append(v) return classes - + def get_importers(self): "Returns list of all ArtImporter subclasses found in formats/ dir." return self.get_converter_classes(ArtImporter) - + def get_exporters(self): "Returns list of all ArtExporter subclasses found in formats/ dir." return self.get_converter_classes(ArtExporter) - + def load_charset(self, charset_to_load, log=False): "creates and returns a character set with the given name" # already loaded? @@ -691,7 +761,7 @@ class Application: elif self.ui and self.ui.active_art: # if init failed (eg bad filename) return something safe return self.ui.active_art.charset - + def load_palette(self, palette_to_load, log=False): base_palette_to_load = os.path.basename(palette_to_load) base_palette_to_load = os.path.splitext(base_palette_to_load)[0] @@ -705,13 +775,13 @@ class Application: elif self.ui and self.ui.active_art: # if init failed (eg bad filename) return something safe return self.ui.active_art.palette - + def set_window_title(self, text=None): # if editing is locked, don't even show Playscii name - new_title = '%s - %s' % (APP_NAME, text) if self.can_edit else str(text) - new_title = bytes(new_title, 'utf-8') + new_title = "%s - %s" % (APP_NAME, text) if self.can_edit else str(text) + new_title = bytes(new_title, "utf-8") sdl2.SDL_SetWindowTitle(self.window, new_title) - + def update_window_title(self): if self.game_mode: if self.gw and self.gw.game_dir: @@ -722,7 +792,7 @@ class Application: else: title = self.gw.game_title else: - title = 'Game Mode' + title = "Game Mode" self.set_window_title(title) return if not self.ui or not self.ui.active_art: @@ -740,9 +810,9 @@ class Application: else: full_filename = filename if self.ui.active_art.unsaved_changes: - full_filename += '*' + full_filename += "*" self.set_window_title(full_filename) - + def resize_window(self, new_width, new_height): GL.glViewport(0, 0, new_width, new_height) self.window_width, self.window_height = new_width, new_height @@ -750,7 +820,7 @@ class Application: self.fb.resize(new_width, new_height) self.camera.window_resized() self.ui.window_resized() - + def toggle_fullscreen(self): self.fullscreen = not self.fullscreen flags = 0 @@ -759,20 +829,21 @@ class Application: sdl2.SDL_SetWindowFullscreen(self.window, flags) # for all intents and purposes, this is like resizing the window self.resize_window(self.window_width, self.window_height) - + def screenshot(self): "saves a date + time-stamped screenshot" - timestamp = time.strftime('%Y-%m-%d_%H-%M-%S') - output_filename = 'playscii_%s.png' % timestamp + timestamp = time.strftime("%Y-%m-%d_%H-%M-%S") + output_filename = "playscii_%s.png" % timestamp w, h = self.window_width, self.window_height - pixels = GL.glReadPixels(0, 0, w, h, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, - outputType=None) + pixels = GL.glReadPixels( + 0, 0, w, h, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, outputType=None + ) pixel_bytes = pixels.flatten().tobytes() - img = Image.frombytes(mode='RGBA', size=(w, h), data=pixel_bytes) + img = Image.frombytes(mode="RGBA", size=(w, h), data=pixel_bytes) img = img.transpose(Image.FLIP_TOP_BOTTOM) - img.save('%s%s' % (self.documents_dir + SCREENSHOT_DIR, output_filename)) - self.log('Saved screenshot %s' % output_filename) - + img.save("%s%s" % (self.documents_dir + SCREENSHOT_DIR, output_filename)) + self.log("Saved screenshot %s" % output_filename) + def enter_game_mode(self): self.game_mode = True self.camera = self.gw.camera @@ -780,29 +851,29 @@ class Application: # cursor might be hovering an object's art, undo preview viz self.cursor.undo_preview_edits() # display message on how to toggle game mode - mode_bind = self.il.get_command_shortcut('toggle_game_mode') + mode_bind = self.il.get_command_shortcut("toggle_game_mode") mode_bind = mode_bind.title() if self.can_edit: self.ui.message_line.post_line(self.game_mode_message % mode_bind, 10) self.al.resume_music() self.ui.menu_bar.close_active_menu() self.ui.menu_bar = self.ui.game_menu_bar - + def exit_game_mode(self): self.game_mode = False self.camera = self.art_camera self.grid = self.art_grid if self.ui.active_art: self.camera.set_for_art(self.ui.active_art) - self.ui.message_line.post_line('', 1) + self.ui.message_line.post_line("", 1) self.update_window_title() self.al.pause_music() self.ui.menu_bar.close_active_menu() self.ui.menu_bar = self.ui.art_menu_bar - + def get_elapsed_time(self): return sdl2.timer.SDL_GetTicks() - + def main_loop(self): self.last_time = self.get_elapsed_time() while not self.should_quit: @@ -825,10 +896,10 @@ class Application: delay = 1000 / self.framerate # subtract work time from delay to maintain framerate delay -= min(delay, dt) - #print('frame time %s, delaying %sms to hit %s' % (self.frame_time, delay, self.framerate)) + # print('frame time %s, delaying %sms to hit %s' % (self.frame_time, delay, self.framerate)) sdl2.timer.SDL_Delay(int(delay)) return 1 - + def update(self): # update whether app has mouse + input (keybaord) focus flags = ctypes.c_uint(0) @@ -857,7 +928,7 @@ class Application: self.last_time += self.timestep self.updates += 1 self.frame_update() - + def frame_update(self): "updates that should happen once per frame" if self.converter: @@ -868,11 +939,15 @@ class Application: else: for art in self.art_loaded_for_edit: art.update() - if self.ui.active_art and \ - not self.ui.console.visible and not self.game_mode and \ - not self.ui.menu_bar in self.ui.hovered_elements and \ - not self.ui.status_bar in self.ui.hovered_elements and \ - not self.ui.menu_bar.active_menu_name and not self.ui.active_dialog: + if ( + self.ui.active_art + and not self.ui.console.visible + and not self.game_mode + and self.ui.menu_bar not in self.ui.hovered_elements + and self.ui.status_bar not in self.ui.hovered_elements + and not self.ui.menu_bar.active_menu_name + and not self.ui.active_dialog + ): self.cursor.update() self.camera.update() if not self.game_mode: @@ -881,39 +956,46 @@ class Application: if self.ui.visible: self.ui.update() self.al.update() - + def debug_onion_frames(self): "debug function to log onion renderable state" # TODO: remove this once it's served its purpose - debug = ['current frame: %s' % self.ui.active_art.active_frame, ''] - debug.append('onion_renderables_prev:') + debug = ["current frame: %s" % self.ui.active_art.active_frame, ""] + debug.append("onion_renderables_prev:") + def get_onion_info(i, r): - visible = 'VISIBLE' if r.visible else '' - return '%s: %s frame %s %s' % (i, r.art.filename.ljust(20), r.frame, visible) - for i,r in enumerate(self.onion_renderables_prev): + visible = "VISIBLE" if r.visible else "" + return "%s: %s frame %s %s" % ( + i, + r.art.filename.ljust(20), + r.frame, + visible, + ) + + for i, r in enumerate(self.onion_renderables_prev): debug.append(get_onion_info(i, r)) - debug.append('') - debug.append('onion_renderables_next:') - for i,r in enumerate(self.onion_renderables_next): + debug.append("") + debug.append("onion_renderables_next:") + for i, r in enumerate(self.onion_renderables_next): debug.append(get_onion_info(i, r)) self.ui.debug_text.post_lines(debug) - + def set_overlay_image(self, image_filename): "sets given image to draw over the active art" try: - img = Image.open(image_filename).convert('RGBA') + img = Image.open(image_filename).convert("RGBA") r = SpriteRenderable(self, image_filename, img) r.alpha = self.default_overlay_image_opacity - except Exception as e: - for line in traceback.format_exc().split('\n')[3:]: + except Exception: + for line in traceback.format_exc().split("\n")[3:]: if line.strip(): self.log(line.rstrip()) return - self.log('Using %s as overlay image.' % image_filename) + self.log("Using %s as overlay image." % image_filename) self.overlay_renderable = r self.ui.size_and_position_overlay_image() self.draw_overlay = True - + def render(self): # draw main scene to framebuffer GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self.fb.framebuffer) @@ -931,7 +1013,7 @@ class Application: self.converter.preview_sprite.render() for r in self.edit_renderables: r.render() - #self.debug_onion_frames() + # self.debug_onion_frames() if self.onion_frames_visible: # draw "nearest" frames first i = 0 @@ -945,12 +1027,15 @@ class Application: if self.ui.active_art: self.grid.render() self.ui.select_tool.render_selections() - if self.ui.active_art and not self.ui.console.visible and \ - not self.ui.menu_bar in self.ui.hovered_elements and \ - not self.ui.status_bar in self.ui.hovered_elements and \ - not self.ui.popup in self.ui.hovered_elements and \ - not self.ui.menu_bar.active_menu_name and \ - not self.ui.active_dialog: + if ( + self.ui.active_art + and not self.ui.console.visible + and self.ui.menu_bar not in self.ui.hovered_elements + and self.ui.status_bar not in self.ui.hovered_elements + and self.ui.popup not in self.ui.hovered_elements + and not self.ui.menu_bar.active_menu_name + and not self.ui.active_dialog + ): self.cursor.render() self.debug_line_renderable.render() if self.draw_overlay and not self.game_mode: @@ -962,28 +1047,28 @@ class Application: self.ui.render() GL.glUseProgram(0) sdl2.SDL_GL_SwapWindow(self.window) - + def save_persistent_setting(self, setting_name, setting_value): # iterate over list backwards so we may safely remove from it for line in reversed(self.config_lines): if line.strip().startswith(setting_name): # ignore lines that contain setting name but don't set it - if line.find('=') == -1: + if line.find("=") == -1: continue # setting already found, remove this redundant line self.config_lines.remove(line) # get current value from top-level scope and write it to end of cfg - self.config_lines += '%s = %s\n' % (setting_name, setting_value) - + self.config_lines += "%s = %s\n" % (setting_name, setting_value) + def save_persistent_config(self): "write options we want to persist across sessions to config file" for name in self.persistent_setting_names: # get current setting value from top-level scope obj, member = self.persistent_setting_names[name] - obj = self if obj == '' else getattr(self, obj) + obj = self if obj == "" else getattr(self, obj) value = getattr(obj, member) self.save_persistent_setting(name, value) - + def restore_session(self): session_filename = self.config_dir + SESSION_FILENAME if not os.path.exists(session_filename): @@ -993,19 +1078,19 @@ class Application: filenames.reverse() for filename in filenames: self.load_art_for_edit(filename.strip()) - + def save_session(self): if not self.can_edit: return # write all currently open art to a file - session_file = open(self.config_dir + SESSION_FILENAME, 'w') + session_file = open(self.config_dir + SESSION_FILENAME, "w") for art in self.art_loaded_for_edit: # if an art has never been saved, don't bother storing it if not os.path.exists(art.filename): continue - session_file.write(art.filename + '\n') + session_file.write(art.filename + "\n") session_file.close() - + def quit(self): if self.init_success: self.save_persistent_config() @@ -1026,19 +1111,19 @@ class Application: sdl2.SDL_DestroyWindow(self.window) sdl2.SDL_Quit() # write to config file - cfg_file = open(self.config_dir + CONFIG_FILENAME, 'w') + cfg_file = open(self.config_dir + CONFIG_FILENAME, "w") cfg_file.writelines(self.config_lines) cfg_file.close() - self.log('Thank you for using Playscii! <3') - + self.log("Thank you for using Playscii! <3") + def edit_cfg(self): cfg_path = self.config_dir + CONFIG_FILENAME - if platform.system() == 'Windows': - editor_bin = 'notepad' - elif platform.system() == 'Darwin': - editor_bin = 'open -a TextEdit' + if platform.system() == "Windows": + editor_bin = "notepad" + elif platform.system() == "Darwin": + editor_bin = "open -a TextEdit" else: - editor_bin = os.environ.get('EDITOR', None) + editor_bin = os.environ.get("EDITOR", None) if not editor_bin: return cmd = '%s "%s"' % (editor_bin, cfg_path) @@ -1048,17 +1133,17 @@ class Application: # execute newly edited cfg! (but only if changes were made?) for line in self.config_lines: exec(line) - + def open_local_url(self, url): "opens given local (this file system) URL in a cross-platform way" - webbrowser.open('file://%s/%s' % (os.getcwd(), url)) - + webbrowser.open("file://%s/%s" % (os.getcwd(), url)) + def open_help_docs(self): self.open_local_url(WEBSITE_HELP_URL) - + def open_website(self): webbrowser.open(WEBSITE_URL) - + def generate_docs(self): # fail gracefully if pdoc not found try: @@ -1069,10 +1154,10 @@ class Application: for module_name in AUTOGEN_DOC_MODULES: # pdoc.pdoc takes module name as string, returns HTML doc string html = pdoc.pdoc(module_name) - docfile = open(AUTOGEN_DOCS_PATH + module_name + '.html', 'w') + docfile = open(AUTOGEN_DOCS_PATH + module_name + ".html", "w") docfile.write(html) docfile.close() - self.log('Documentation generated successfully.') + self.log("Documentation generated successfully.") # open ToC page self.open_local_url(AUTOGEN_DOCS_PATH + AUTOGEN_DOC_TOC_PAGE) @@ -1081,49 +1166,64 @@ def get_win_documents_path(): # from http://stackoverflow.com/a/30924555/1191587 # (winshell module too much of a pain to get working with py2exe) import ctypes.wintypes - CSIDL_PERSONAL = 5 # My Documents - SHGFP_TYPE_CURRENT = 1 # Get current, not default value + + CSIDL_PERSONAL = 5 # My Documents + SHGFP_TYPE_CURRENT = 1 # Get current, not default value buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) - ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf) + ctypes.windll.shell32.SHGetFolderPathW( + None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf + ) return buf.value + def get_paths(): # pass False as second arg to disable "app author" windows dir convention - config_dir = appdirs.user_config_dir(APP_NAME, False) + '/' - cache_dir = appdirs.user_cache_dir(APP_NAME, False) + '/' + config_dir = appdirs.user_config_dir(APP_NAME, False) + "/" + cache_dir = appdirs.user_cache_dir(APP_NAME, False) + "/" if not os.path.exists(config_dir): os.mkdir(config_dir) if not os.path.exists(cache_dir): os.mkdir(cache_dir) if not os.path.exists(cache_dir + THUMBNAIL_CACHE_DIR): os.mkdir(cache_dir + THUMBNAIL_CACHE_DIR) - DOCUMENTS_SUBDIR = '/Documents' - if platform.system() == 'Windows': + DOCUMENTS_SUBDIR = "/Documents" + if platform.system() == "Windows": documents_dir = get_win_documents_path() # issue #18: win documents path may not exist?! if not os.path.exists(documents_dir): os.mkdir(documents_dir) - elif platform.system() == 'Darwin': - documents_dir = os.path.expanduser('~') + DOCUMENTS_SUBDIR + elif platform.system() == "Darwin": + documents_dir = os.path.expanduser("~") + DOCUMENTS_SUBDIR # assume anything that isn't Win/Mac is a UNIX else: # XDG spec doesn't cover any concept of a documents folder :[ # if ~/Documents exists use that, else just use ~/Playscii - documents_dir = os.path.expanduser('~') + documents_dir = os.path.expanduser("~") if os.path.exists(documents_dir + DOCUMENTS_SUBDIR): documents_dir += DOCUMENTS_SUBDIR # add Playscii/ to documents path - documents_dir += '/%s/' % APP_NAME + documents_dir += "/%s/" % APP_NAME # create Playscii dir AND subdirs for user art, charsets etc if not present - for subdir in ['', ART_DIR, CHARSET_DIR, PALETTE_DIR, FORMATS_DIR, - ART_SCRIPT_DIR, SCREENSHOT_DIR, TOP_GAME_DIR]: + for subdir in [ + "", + ART_DIR, + CHARSET_DIR, + PALETTE_DIR, + FORMATS_DIR, + ART_SCRIPT_DIR, + SCREENSHOT_DIR, + TOP_GAME_DIR, + ]: new_dir = os.path.abspath(documents_dir + subdir) # os.path.exists can fail in Windows b/c case insensitivity, # so just try and fail :[ - try: os.mkdir(new_dir) - except: pass + try: + os.mkdir(new_dir) + except: + pass return config_dir, documents_dir, cache_dir + def get_version(): return open(VERSION_FILENAME).readlines()[0].strip() @@ -1133,18 +1233,19 @@ class Logger: Minimal object for logging, starts very early so we can write to it even before Application has initialized. """ + def __init__(self, config_dir): self.lines = [] config_dir, docs_dir, cache_dir = get_paths() # use line buffering (last lines should appear even in case of crash) bufsize = 1 - self.log_file = open(config_dir + LOG_FILENAME, 'w', bufsize) - + self.log_file = open(config_dir + LOG_FILENAME, "w", bufsize) + def log(self, new_line): - self.log_file.write('%s\n' % new_line) + self.log_file.write("%s\n" % new_line) self.lines.append(str(new_line)) print(new_line) - + def close(self): self.log_file.close() @@ -1156,7 +1257,7 @@ def get_app(): # start logger even before Application has initialized so we can write to it # startup message: application and version # logger = Logger(config_dir) - logger.log('%s v%s' % (APP_NAME, get_version())) + logger.log("%s v%s" % (APP_NAME, get_version())) # see if "autoplay this game" file exists and has anything in it autoplay_game = None if os.path.exists(AUTOPLAY_GAME_FILENAME): @@ -1166,60 +1267,68 @@ def get_app(): # load in config - may change above values and submodule class defaults cfg_filename = config_dir + CONFIG_FILENAME if os.path.exists(cfg_filename): - logger.log('Loading config from %s...' % cfg_filename) + logger.log("Loading config from %s..." % cfg_filename) # execute cfg line by line so we can continue past lines with errors. # this does mean that commenting out blocks with triple-quotes fails, # but that's not a good practice anyway. cfg_lines = open(cfg_filename).readlines() # compile a new cfg with any error lines stripped out new_cfg_lines = [] - for i,cfg_line in enumerate(cfg_lines): + for i, cfg_line in enumerate(cfg_lines): cfg_line = cfg_line.strip() try: exec(cfg_line) - new_cfg_lines.append(cfg_line + '\n') + new_cfg_lines.append(cfg_line + "\n") except: # find line with "Error", ie the exception name, log that - error_lines = traceback.format_exc().split('\n') - error = '[an unknown error]' + error_lines = traceback.format_exc().split("\n") + error = "[an unknown error]" for el in error_lines: - if 'Error' in el: + if "Error" in el: error = el break - logger.log(' Removing line %s with %s' % (i, error)) - new_cfg = open(cfg_filename, 'w') + logger.log(" Removing line %s with %s" % (i, error)) + new_cfg = open(cfg_filename, "w") new_cfg.writelines(new_cfg_lines) new_cfg.close() - logger.log('Config loaded.') + logger.log("Config loaded.") # if cfg file doesn't exist, copy a new one from playscii.cfg.default else: # snip first "this is a template" line default_data = open(CONFIG_TEMPLATE_FILENAME).readlines()[1:] - new_cfg = open(cfg_filename, 'w') + new_cfg = open(cfg_filename, "w") new_cfg.writelines(default_data) new_cfg.close() - exec(''.join(default_data)) - logger.log('Created new config file %s' % cfg_filename) + exec("".join(default_data)) + logger.log("Created new config file %s" % cfg_filename) art_to_load, game_dir_to_load, state_to_load = None, None, None # usage: # playscii.py [artfile] | [-game gamedir [-state statefile | artfile]] if len(sys.argv) > 1: # "-game test1" args will set test1/ as game dir - if len(sys.argv) > 2 and sys.argv[1] == '-game': + if len(sys.argv) > 2 and sys.argv[1] == "-game": game_dir_to_load = sys.argv[2] # "-state testX" args will load testX game state from given game dir - if len(sys.argv) > 4 and sys.argv[3] == '-state': + if len(sys.argv) > 4 and sys.argv[3] == "-state": state_to_load = sys.argv[4] elif len(sys.argv) > 3: art_to_load = sys.argv[3] else: # else assume first arg is an art file to load in art mode art_to_load = sys.argv[1] - app = Application(config_dir, documents_dir, cache_dir, logger, - art_to_load or DEFAULT_ART_FILENAME, game_dir_to_load, - state_to_load, autoplay_game) + app = Application( + config_dir, + documents_dir, + cache_dir, + logger, + art_to_load or DEFAULT_ART_FILENAME, + game_dir_to_load, + state_to_load, + autoplay_game, + ) return app + if __name__ == "__main__": app = get_app() error = app.main_loop() diff --git a/renderable.py b/renderable.py index 8727efa..c3fa184 100644 --- a/renderable.py +++ b/renderable.py @@ -1,6 +1,9 @@ -import os, math, ctypes +import ctypes +import math + import numpy as np from OpenGL import GL + from art import VERT_LENGTH from palette import MAX_COLORS @@ -16,22 +19,23 @@ class TileRenderable: rectangular OpenGL triangle-pairs. Animation frames are uploaded into our buffers from source Art's numpy arrays. """ - vert_shader_source = 'renderable_v.glsl' + + vert_shader_source = "renderable_v.glsl" "vertex shader: includes view projection matrix, XYZ camera uniforms." - frag_shader_source = 'renderable_f.glsl' + frag_shader_source = "renderable_f.glsl" "Pixel shader: handles FG/BG colors." log_create_destroy = False log_animation = False log_buffer_updates = False - grain_strength = 0. - alpha = 1. + grain_strength = 0.0 + alpha = 1.0 "Alpha (0 to 1) for entire Renderable." - bg_alpha = 1. + bg_alpha = 1.0 "Alpha (0 to 1) *only* for tile background colors." default_move_rate = 1 use_art_offset = True "Use game object's art_off_pct values." - + def __init__(self, app, art, game_object=None): "Create Renderable with given Art, optionally bound to given GameObject" self.app = app @@ -69,68 +73,128 @@ class TileRenderable: if self.app.use_vao: self.vao = GL.glGenVertexArrays(1) GL.glBindVertexArray(self.vao) - self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source) - self.proj_matrix_uniform = self.shader.get_uniform_location('projection') - self.view_matrix_uniform = self.shader.get_uniform_location('view') - self.position_uniform = self.shader.get_uniform_location('objectPosition') - self.scale_uniform = self.shader.get_uniform_location('objectScale') - self.charset_width_uniform = self.shader.get_uniform_location('charMapWidth') - self.charset_height_uniform = self.shader.get_uniform_location('charMapHeight') - self.char_uv_width_uniform = self.shader.get_uniform_location('charUVWidth') - self.char_uv_height_uniform = self.shader.get_uniform_location('charUVHeight') - self.charset_tex_uniform = self.shader.get_uniform_location('charset') - self.palette_tex_uniform = self.shader.get_uniform_location('palette') - self.grain_tex_uniform = self.shader.get_uniform_location('grain') - self.palette_width_uniform = self.shader.get_uniform_location('palTextureWidth') - self.grain_strength_uniform = self.shader.get_uniform_location('grainStrength') - self.alpha_uniform = self.shader.get_uniform_location('alpha') - self.brightness_uniform = self.shader.get_uniform_location('brightness') - self.bg_alpha_uniform = self.shader.get_uniform_location('bgColorAlpha') + self.shader = self.app.sl.new_shader( + self.vert_shader_source, self.frag_shader_source + ) + self.proj_matrix_uniform = self.shader.get_uniform_location("projection") + self.view_matrix_uniform = self.shader.get_uniform_location("view") + self.position_uniform = self.shader.get_uniform_location("objectPosition") + self.scale_uniform = self.shader.get_uniform_location("objectScale") + self.charset_width_uniform = self.shader.get_uniform_location("charMapWidth") + self.charset_height_uniform = self.shader.get_uniform_location("charMapHeight") + self.char_uv_width_uniform = self.shader.get_uniform_location("charUVWidth") + self.char_uv_height_uniform = self.shader.get_uniform_location("charUVHeight") + self.charset_tex_uniform = self.shader.get_uniform_location("charset") + self.palette_tex_uniform = self.shader.get_uniform_location("palette") + self.grain_tex_uniform = self.shader.get_uniform_location("grain") + self.palette_width_uniform = self.shader.get_uniform_location("palTextureWidth") + self.grain_strength_uniform = self.shader.get_uniform_location("grainStrength") + self.alpha_uniform = self.shader.get_uniform_location("alpha") + self.brightness_uniform = self.shader.get_uniform_location("brightness") + self.bg_alpha_uniform = self.shader.get_uniform_location("bgColorAlpha") self.create_buffers() # finish if self.app.use_vao: GL.glBindVertexArray(0) if self.log_create_destroy: - self.app.log('created: %s' % self) - + self.app.log("created: %s" % self) + def __str__(self): "for debug purposes, return a concise unique name" - for i,r in enumerate(self.art.renderables): + for i, r in enumerate(self.art.renderables): if r is self: break - return '%s %s %s' % (self.art.get_simple_name(), self.__class__.__name__, i) - + return "%s %s %s" % (self.art.get_simple_name(), self.__class__.__name__, i) + def create_buffers(self): # vertex positions and elements # determine vertex count needed for render self.vert_count = int(len(self.art.elem_array)) self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2) - self.update_buffer(self.vert_buffer, self.art.vert_array, - GL.GL_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_FLOAT, 'vertPosition', VERT_LENGTH) - self.update_buffer(self.elem_buffer, self.art.elem_array, - GL.GL_ELEMENT_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_UNSIGNED_INT, None, None) + self.update_buffer( + self.vert_buffer, + self.art.vert_array, + GL.GL_ARRAY_BUFFER, + GL.GL_STATIC_DRAW, + GL.GL_FLOAT, + "vertPosition", + VERT_LENGTH, + ) + self.update_buffer( + self.elem_buffer, + self.art.elem_array, + GL.GL_ELEMENT_ARRAY_BUFFER, + GL.GL_STATIC_DRAW, + GL.GL_UNSIGNED_INT, + None, + None, + ) # tile data buffers # use GL_DYNAMIC_DRAW given they change every time a char/color changes self.char_buffer, self.uv_buffer = GL.glGenBuffers(2) # character indices (which become vertex UVs) - self.update_buffer(self.char_buffer, self.art.chars[self.frame], - GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'charIndex', 1) + self.update_buffer( + self.char_buffer, + self.art.chars[self.frame], + GL.GL_ARRAY_BUFFER, + GL.GL_DYNAMIC_DRAW, + GL.GL_FLOAT, + "charIndex", + 1, + ) # UV "mods" - modify UV derived from character index - self.update_buffer(self.uv_buffer, self.art.uv_mods[self.frame], - GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'uvMod', 2) + self.update_buffer( + self.uv_buffer, + self.art.uv_mods[self.frame], + GL.GL_ARRAY_BUFFER, + GL.GL_DYNAMIC_DRAW, + GL.GL_FLOAT, + "uvMod", + 2, + ) self.fg_buffer, self.bg_buffer = GL.glGenBuffers(2) # foreground/background color indices (which become rgba colors) - self.update_buffer(self.fg_buffer, self.art.fg_colors[self.frame], - GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'fgColorIndex', 1) - self.update_buffer(self.bg_buffer, self.art.bg_colors[self.frame], - GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, GL.GL_FLOAT, 'bgColorIndex', 1) - + self.update_buffer( + self.fg_buffer, + self.art.fg_colors[self.frame], + GL.GL_ARRAY_BUFFER, + GL.GL_DYNAMIC_DRAW, + GL.GL_FLOAT, + "fgColorIndex", + 1, + ) + self.update_buffer( + self.bg_buffer, + self.art.bg_colors[self.frame], + GL.GL_ARRAY_BUFFER, + GL.GL_DYNAMIC_DRAW, + GL.GL_FLOAT, + "bgColorIndex", + 1, + ) + def update_geo_buffers(self): - self.update_buffer(self.vert_buffer, self.art.vert_array, GL.GL_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_FLOAT, None, None) - self.update_buffer(self.elem_buffer, self.art.elem_array, GL.GL_ELEMENT_ARRAY_BUFFER, GL.GL_STATIC_DRAW, GL.GL_UNSIGNED_INT, None, None) + self.update_buffer( + self.vert_buffer, + self.art.vert_array, + GL.GL_ARRAY_BUFFER, + GL.GL_STATIC_DRAW, + GL.GL_FLOAT, + None, + None, + ) + self.update_buffer( + self.elem_buffer, + self.art.elem_array, + GL.GL_ELEMENT_ARRAY_BUFFER, + GL.GL_STATIC_DRAW, + GL.GL_UNSIGNED_INT, + None, + None, + ) # total vertex count probably changed self.vert_count = int(len(self.art.elem_array)) - + def update_tile_buffers(self, update_chars, update_uvs, update_fg, update_bg): "Update GL data arrays for tile characters, fg/bg colors, transforms." updates = {} @@ -143,32 +207,58 @@ class TileRenderable: if update_bg: updates[self.bg_buffer] = self.art.bg_colors for update in updates: - self.update_buffer(update, updates[update][self.frame], - GL.GL_ARRAY_BUFFER, GL.GL_DYNAMIC_DRAW, - GL.GL_FLOAT, None, None) - - def update_buffer(self, buffer_index, array, target, buffer_type, data_type, - attrib_name, attrib_size): + self.update_buffer( + update, + updates[update][self.frame], + GL.GL_ARRAY_BUFFER, + GL.GL_DYNAMIC_DRAW, + GL.GL_FLOAT, + None, + None, + ) + + def update_buffer( + self, + buffer_index, + array, + target, + buffer_type, + data_type, + attrib_name, + attrib_size, + ): if self.log_buffer_updates: - self.app.log('update_buffer: %s, %s, %s, %s, %s, %s, %s' % (buffer_index, array, target, buffer_type, data_type, attrib_name, attrib_size)) + self.app.log( + "update_buffer: %s, %s, %s, %s, %s, %s, %s" + % ( + buffer_index, + array, + target, + buffer_type, + data_type, + attrib_name, + attrib_size, + ) + ) GL.glBindBuffer(target, buffer_index) GL.glBufferData(target, array.nbytes, array, buffer_type) if attrib_name: attrib = self.shader.get_attrib_location(attrib_name) GL.glEnableVertexAttribArray(attrib) - GL.glVertexAttribPointer(attrib, attrib_size, data_type, - GL.GL_FALSE, 0, ctypes.c_void_p(0)) + GL.glVertexAttribPointer( + attrib, attrib_size, data_type, GL.GL_FALSE, 0, ctypes.c_void_p(0) + ) # unbind each buffer before binding next GL.glBindBuffer(target, 0) - + def advance_frame(self): "Advance to our Art's next animation frame." self.set_frame(self.frame + 1) - + def rewind_frame(self): "Rewind to our Art's previous animation frame." self.set_frame(self.frame - 1) - + def set_frame(self, new_frame_index): "Set us to display our Art's given animation frame." if new_frame_index == self.frame: @@ -177,20 +267,22 @@ class TileRenderable: self.frame = new_frame_index % self.art.frames self.update_tile_buffers(True, True, True, True) if self.log_animation: - self.app.log('%s animating from frames %s to %s' % (self, old_frame, self.frame)) - + self.app.log( + "%s animating from frames %s to %s" % (self, old_frame, self.frame) + ) + def start_animating(self): "Start animation playback." self.animating = True self.anim_timer = 0 - + def stop_animating(self): "Pause animation playback on current frame (in game mode)." self.animating = False # restore to active frame if stopping if not self.app.game_mode: self.set_frame(self.art.active_frame) - + def set_art(self, new_art): "Display and bind to given Art." if self.art: @@ -202,12 +294,12 @@ class TileRenderable: self.frame %= self.art.frames self.update_geo_buffers() self.update_tile_buffers(True, True, True, True) - #print('%s now uses Art %s' % (self, self.art.filename)) - + # print('%s now uses Art %s' % (self, self.art.filename)) + def reset_size(self): self.width = self.art.width * self.art.quad_width * abs(self.scale_x) self.height = self.art.height * self.art.quad_height * self.scale_y - + def move_to(self, x, y, z, travel_time=None): """ Start simple linear interpolation to given destination over given time. @@ -220,20 +312,22 @@ class TileRenderable: dx = x - self.x dy = y - self.y dz = z - self.z - dist = math.sqrt(dx ** 2 + dy ** 2 + dz ** 2) + dist = math.sqrt(dx**2 + dy**2 + dz**2) self.move_rate = dist / frames else: self.move_rate = self.default_move_rate self.ui_moving = True self.goal_x, self.goal_y, self.goal_z = x, y, z if self.log_animation: - self.app.log('%s will move to %s,%s' % (self.art.filename, self.goal_x, self.goal_y)) - + self.app.log( + "%s will move to %s,%s" % (self.art.filename, self.goal_x, self.goal_y) + ) + def snap_to(self, x, y, z): self.x, self.y, self.z = x, y, z self.goal_x, self.goal_y, self.goal_z = x, y, z self.ui_moving = False - + def update_transform_from_object(self, obj): "Update our position & scale based on that of given game object." self.z = obj.z @@ -250,14 +344,14 @@ class TileRenderable: if obj.flip_x: self.scale_x *= -1 self.scale_z = obj.scale_z - + def update_loc(self): # TODO: probably time to bust out the ol' vector module for this stuff # get delta dx = self.goal_x - self.x dy = self.goal_y - self.y dz = self.goal_z - self.z - dist = math.sqrt(dx ** 2 + dy ** 2 + dz ** 2) + dist = math.sqrt(dx**2 + dy**2 + dz**2) # close enough? if dist <= self.move_rate: self.x = self.goal_x @@ -273,8 +367,8 @@ class TileRenderable: self.x += self.move_rate * dir_x self.y += self.move_rate * dir_y self.z += self.move_rate * dir_z - #self.app.log('%s moved to %s,%s' % (self, self.x, self.y)) - + # self.app.log('%s moved to %s,%s' % (self, self.x, self.y)) + def update(self): if self.go: self.update_transform_from_object(self.go) @@ -297,31 +391,41 @@ class TileRenderable: # TODO: if new_frame < self.frame, count anim loop? self.set_frame(new_frame) self.last_frame_time = self.app.get_elapsed_time() - + def destroy(self): if self.app.use_vao: GL.glDeleteVertexArrays(1, [self.vao]) - GL.glDeleteBuffers(6, [self.vert_buffer, self.elem_buffer, self.char_buffer, self.uv_buffer, self.fg_buffer, self.bg_buffer]) + GL.glDeleteBuffers( + 6, + [ + self.vert_buffer, + self.elem_buffer, + self.char_buffer, + self.uv_buffer, + self.fg_buffer, + self.bg_buffer, + ], + ) if self.art and self in self.art.renderables: self.art.renderables.remove(self) if self.log_create_destroy: - self.app.log('destroyed: %s' % self) - + self.app.log("destroyed: %s" % self) + def get_projection_matrix(self): """ UIRenderable overrides this so it doesn't have to override Renderable.render and duplicate lots of code. """ return np.eye(4, 4) if self.exporting else self.camera.projection_matrix - + def get_view_matrix(self): return np.eye(4, 4) if self.exporting else self.camera.view_matrix - + def get_loc(self): "Returns world space location as (x, y, z) tuple." export_loc = (-1, 1, 0) return export_loc if self.exporting else (self.x, self.y, self.z) - + def get_scale(self): "Returns world space scale as (x, y, z) tuple." if not self.exporting: @@ -329,7 +433,7 @@ class TileRenderable: x = 2 / (self.art.width * self.art.quad_width) y = 2 / (self.art.height * self.art.quad_height) return (x, y, 1) - + def render_frame_for_export(self, frame): self.exporting = True self.set_frame(frame) @@ -344,7 +448,7 @@ class TileRenderable: self.render() self.art.app.inactive_layer_visibility = ilv self.exporting = False - + def render(self, layers=None, z_override=None, brightness=1.0): """ Render given list of layers at given Z depth. @@ -374,10 +478,12 @@ class TileRenderable: GL.glUniform1f(self.palette_width_uniform, MAX_COLORS) GL.glUniform1f(self.grain_strength_uniform, self.grain_strength) # camera uniforms - GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, - self.get_projection_matrix()) - GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, - self.get_view_matrix()) + GL.glUniformMatrix4fv( + self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix() + ) + GL.glUniformMatrix4fv( + self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix() + ) # TODO: determine if cost of setting all above uniforms for each # Renderable is significant enough to warrant opti where they're set once GL.glUniform1f(self.bg_alpha_uniform, self.bg_alpha) @@ -387,29 +493,39 @@ class TileRenderable: if self.app.use_vao: GL.glBindVertexArray(self.vao) else: - attrib = self.shader.get_attrib_location # for brevity + attrib = self.shader.get_attrib_location # for brevity vp = ctypes.c_void_p(0) # bind each buffer and set its attrib: # verts GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer) - GL.glVertexAttribPointer(attrib('vertPosition'), VERT_LENGTH, GL.GL_FLOAT, GL.GL_FALSE, 0, vp) - GL.glEnableVertexAttribArray(attrib('vertPosition')) + GL.glVertexAttribPointer( + attrib("vertPosition"), VERT_LENGTH, GL.GL_FLOAT, GL.GL_FALSE, 0, vp + ) + GL.glEnableVertexAttribArray(attrib("vertPosition")) # chars GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.char_buffer) - GL.glVertexAttribPointer(attrib('charIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp) - GL.glEnableVertexAttribArray(attrib('charIndex')) + GL.glVertexAttribPointer( + attrib("charIndex"), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp + ) + GL.glEnableVertexAttribArray(attrib("charIndex")) # uvs GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.uv_buffer) - GL.glVertexAttribPointer(attrib('uvMod'), 2, GL.GL_FLOAT, GL.GL_FALSE, 0, vp) - GL.glEnableVertexAttribArray(attrib('uvMod')) + GL.glVertexAttribPointer( + attrib("uvMod"), 2, GL.GL_FLOAT, GL.GL_FALSE, 0, vp + ) + GL.glEnableVertexAttribArray(attrib("uvMod")) # fg colors GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.fg_buffer) - GL.glVertexAttribPointer(attrib('fgColorIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp) - GL.glEnableVertexAttribArray(attrib('fgColorIndex')) + GL.glVertexAttribPointer( + attrib("fgColorIndex"), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp + ) + GL.glEnableVertexAttribArray(attrib("fgColorIndex")) # bg colors GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.bg_buffer) - GL.glVertexAttribPointer(attrib('bgColorIndex'), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp) - GL.glEnableVertexAttribArray(attrib('bgColorIndex')) + GL.glVertexAttribPointer( + attrib("bgColorIndex"), 1, GL.GL_FLOAT, GL.GL_FALSE, 0, vp + ) + GL.glEnableVertexAttribArray(attrib("bgColorIndex")) # finally, bind element buffer GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer) GL.glEnable(GL.GL_BLEND) @@ -430,8 +546,14 @@ class TileRenderable: layer_start = i * layer_size layer_end = layer_start + layer_size # for active art, dim all but active layer based on UI setting - if not self.app.game_mode and self.art is self.app.ui.active_art and i != self.art.active_layer: - GL.glUniform1f(self.alpha_uniform, self.alpha * self.app.inactive_layer_visibility) + if ( + not self.app.game_mode + and self.art is self.app.ui.active_art + and i != self.art.active_layer + ): + GL.glUniform1f( + self.alpha_uniform, self.alpha * self.app.inactive_layer_visibility + ) else: GL.glUniform1f(self.alpha_uniform, self.alpha) # use position offset instead of baked-in Z for layers - this @@ -442,8 +564,12 @@ class TileRenderable: z += self.art.layers_z[i] z = z_override if z_override else z GL.glUniform3f(self.position_uniform, x, y, z) - GL.glDrawElements(GL.GL_TRIANGLES, layer_size, GL.GL_UNSIGNED_INT, - ctypes.c_void_p(layer_start * ctypes.sizeof(ctypes.c_uint))) + GL.glDrawElements( + GL.GL_TRIANGLES, + layer_size, + GL.GL_UNSIGNED_INT, + ctypes.c_void_p(layer_start * ctypes.sizeof(ctypes.c_uint)), + ) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0) GL.glDisable(GL.GL_BLEND) if self.app.use_vao: @@ -452,23 +578,21 @@ class TileRenderable: class OnionTileRenderable(TileRenderable): - "TileRenderable subclass used for onion skin display in Art Mode animation." - + # never animate def start_animating(self): pass - + def stop_animating(self): pass class GameObjectRenderable(TileRenderable): - """ TileRenderable subclass used by GameObjects. Almost no custom logic for now. """ - + def get_loc(self): """ Returns world space location as (x, y, z) tuple, offset by our diff --git a/renderable_line.py b/renderable_line.py index 799fff0..06b6a8b 100644 --- a/renderable_line.py +++ b/renderable_line.py @@ -1,15 +1,20 @@ -import math, time, ctypes, platform +import ctypes +import math +import platform +import time + import numpy as np from OpenGL import GL + from renderable import TileRenderable -class LineRenderable(): - + +class LineRenderable: "Renderable comprised of GL_LINES" - - vert_shader_source = 'lines_v.glsl' - vert_shader_source_3d = 'lines_3d_v.glsl' - frag_shader_source = 'lines_f.glsl' + + vert_shader_source = "lines_v.glsl" + vert_shader_source_3d = "lines_3d_v.glsl" + frag_shader_source = "lines_f.glsl" log_create_destroy = False line_width = 1 # items in vert array: 2 for XY-only renderables, 3 for ones that include Z @@ -17,12 +22,12 @@ class LineRenderable(): # use game object's art_off_pct values use_art_offset = True visible = True - + def __init__(self, app, quad_size_ref=None, game_object=None): self.app = app # we may be attached to a game object self.go = game_object - self.unique_name = '%s_%s' % (int(time.time()), self.__class__.__name__) + self.unique_name = "%s_%s" % (int(time.time()), self.__class__.__name__) self.quad_size_ref = quad_size_ref self.x, self.y, self.z = 0, 0, 0 self.scale_x, self.scale_y = 1, 1 @@ -36,123 +41,155 @@ class LineRenderable(): GL.glBindVertexArray(self.vao) if self.vert_items == 3: self.vert_shader_source = self.vert_shader_source_3d - self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source) + self.shader = self.app.sl.new_shader( + self.vert_shader_source, self.frag_shader_source + ) # uniforms - self.proj_matrix_uniform = self.shader.get_uniform_location('projection') - self.view_matrix_uniform = self.shader.get_uniform_location('view') - self.position_uniform = self.shader.get_uniform_location('objectPosition') - self.scale_uniform = self.shader.get_uniform_location('objectScale') - self.quad_size_uniform = self.shader.get_uniform_location('quadSize') - self.color_uniform = self.shader.get_uniform_location('objectColor') + self.proj_matrix_uniform = self.shader.get_uniform_location("projection") + self.view_matrix_uniform = self.shader.get_uniform_location("view") + self.position_uniform = self.shader.get_uniform_location("objectPosition") + self.scale_uniform = self.shader.get_uniform_location("objectScale") + self.quad_size_uniform = self.shader.get_uniform_location("quadSize") + self.color_uniform = self.shader.get_uniform_location("objectColor") # vert buffers self.vert_buffer, self.elem_buffer = GL.glGenBuffers(2) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer) - GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes, - self.vert_array, GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self.vert_array.nbytes, + self.vert_array, + GL.GL_STATIC_DRAW, + ) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer) - GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes, - self.elem_array, GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ELEMENT_ARRAY_BUFFER, + self.elem_array.nbytes, + self.elem_array, + GL.GL_STATIC_DRAW, + ) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0) self.vert_count = int(len(self.elem_array)) - self.pos_attrib = self.shader.get_attrib_location('vertPosition') + self.pos_attrib = self.shader.get_attrib_location("vertPosition") GL.glEnableVertexAttribArray(self.pos_attrib) offset = ctypes.c_void_p(0) - GL.glVertexAttribPointer(self.pos_attrib, self.vert_items, - GL.GL_FLOAT, GL.GL_FALSE, 0, offset) + GL.glVertexAttribPointer( + self.pos_attrib, self.vert_items, GL.GL_FLOAT, GL.GL_FALSE, 0, offset + ) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) # vert colors self.color_buffer = GL.glGenBuffers(1) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer) - GL.glBufferData(GL.GL_ARRAY_BUFFER, self.color_array.nbytes, - self.color_array, GL.GL_STATIC_DRAW) - self.color_attrib = self.shader.get_attrib_location('vertColor') + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self.color_array.nbytes, + self.color_array, + GL.GL_STATIC_DRAW, + ) + self.color_attrib = self.shader.get_attrib_location("vertColor") GL.glEnableVertexAttribArray(self.color_attrib) - GL.glVertexAttribPointer(self.color_attrib, 4, - GL.GL_FLOAT, GL.GL_FALSE, 0, offset) + GL.glVertexAttribPointer( + self.color_attrib, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, offset + ) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) if self.app.use_vao: GL.glBindVertexArray(0) if self.log_create_destroy: - self.app.log('created: %s' % self) - + self.app.log("created: %s" % self) + def __str__(self): "for debug purposes, return a unique name" return self.unique_name - + def build_geo(self): """ create self.vert_array, self.elem_array, self.color_array """ pass - + def reset_loc(self): pass - + def update(self): if self.go: self.update_transform_from_object(self.go) - + def reset_size(self): self.width, self.height = self.get_size() - + def update_transform_from_object(self, obj): TileRenderable.update_transform_from_object(self, obj) - + def rebind_buffers(self): # resend verts GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer) - GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes, - self.vert_array, GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self.vert_array.nbytes, + self.vert_array, + GL.GL_STATIC_DRAW, + ) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer) - GL.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_array.nbytes, - self.elem_array, GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ELEMENT_ARRAY_BUFFER, + self.elem_array.nbytes, + self.elem_array, + GL.GL_STATIC_DRAW, + ) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) self.vert_count = int(len(self.elem_array)) # resend color GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer) - GL.glBufferData(GL.GL_ARRAY_BUFFER, self.color_array.nbytes, - self.color_array, GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self.color_array.nbytes, + self.color_array, + GL.GL_STATIC_DRAW, + ) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) - + def get_projection_matrix(self): return np.eye(4, 4) - + def get_view_matrix(self): return np.eye(4, 4) - + def get_loc(self): return self.x, self.y, self.z - + def get_size(self): # overriden in subclasses that need specific width/height data return 1, 1 - + def get_quad_size(self): if self.quad_size_ref: return self.quad_size_ref.quad_width, self.quad_size_ref.quad_height else: return 1, 1 - + def get_color(self): return (1, 1, 1, 1) - + def get_line_width(self): return self.line_width - + def destroy(self): if self.app.use_vao: GL.glDeleteVertexArrays(1, [self.vao]) GL.glDeleteBuffers(3, [self.vert_buffer, self.elem_buffer, self.color_buffer]) if self.log_create_destroy: - self.app.log('destroyed: %s' % self) - + self.app.log("destroyed: %s" % self) + def render(self): if not self.visible: return GL.glUseProgram(self.shader.program) - GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix()) - GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix()) + GL.glUniformMatrix4fv( + self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix() + ) + GL.glUniformMatrix4fv( + self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix() + ) GL.glUniform3f(self.position_uniform, *self.get_loc()) GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z) GL.glUniform2f(self.quad_size_uniform, *self.get_quad_size()) @@ -165,22 +202,28 @@ class LineRenderable(): # attribs: # pos GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer) - GL.glVertexAttribPointer(self.pos_attrib, self.vert_items, - GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0)) + GL.glVertexAttribPointer( + self.pos_attrib, + self.vert_items, + GL.GL_FLOAT, + GL.GL_FALSE, + 0, + ctypes.c_void_p(0), + ) GL.glEnableVertexAttribArray(self.pos_attrib) # color GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.color_buffer) - GL.glVertexAttribPointer(self.color_attrib, 4, - GL.GL_FLOAT, GL.GL_FALSE, 0, offset) + GL.glVertexAttribPointer( + self.color_attrib, 4, GL.GL_FLOAT, GL.GL_FALSE, 0, offset + ) GL.glEnableVertexAttribArray(self.color_attrib) # bind elem array - see similar behavior in Cursor.render GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, self.elem_buffer) GL.glEnable(GL.GL_BLEND) GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) - if platform.system() != 'Darwin': + if platform.system() != "Darwin": GL.glLineWidth(self.get_line_width()) - GL.glDrawElements(GL.GL_LINES, self.vert_count, - GL.GL_UNSIGNED_INT, None) + GL.glDrawElements(GL.GL_LINES, self.vert_count, GL.GL_UNSIGNED_INT, None) GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0) GL.glDisable(GL.GL_BLEND) if self.app.use_vao: @@ -191,6 +234,7 @@ class LineRenderable(): # common data/code used by various boxes BOX_VERTS = [(0, 0), (1, 0), (1, -1), (0, -1)] + def get_box_arrays(vert_list=None, color=(1, 1, 1, 1)): verts = np.array(vert_list or BOX_VERTS, dtype=np.float32) elems = np.array([0, 1, 1, 2, 2, 3, 3, 0], dtype=np.uint32) @@ -199,11 +243,11 @@ def get_box_arrays(vert_list=None, color=(1, 1, 1, 1)): class UIRenderableX(LineRenderable): - "Red X used to denote transparent color in various places" + color = (1, 0, 0, 1) line_width = 2 - + def build_geo(self): self.vert_array = np.array([(0, 0), (1, 1), (1, 0), (0, 1)], dtype=np.float32) self.elem_array = np.array([0, 1, 2, 3], dtype=np.uint32) @@ -211,55 +255,56 @@ class UIRenderableX(LineRenderable): class SwatchSelectionBoxRenderable(LineRenderable): - "used for UI selection boxes etc" - + color = (0.5, 0.5, 0.5, 1) line_width = 2 - + def __init__(self, app, quad_size_ref): LineRenderable.__init__(self, app, quad_size_ref) # track tile X and Y for cursor movement - self.tile_x, self.tile_y = 0,0 - + self.tile_x, self.tile_y = 0, 0 + def get_color(self): return self.color - + def build_geo(self): - self.vert_array, self.elem_array, self.color_array = get_box_arrays(None, self.color) + self.vert_array, self.elem_array, self.color_array = get_box_arrays( + None, self.color + ) class ToolSelectionBoxRenderable(LineRenderable): line_width = 2 - + def get_color(self): return (1.0, 1.0, 1.0, 1.0) - + def build_geo(self): self.vert_array, self.elem_array, self.color_array = get_box_arrays(None) class WorldLineRenderable(LineRenderable): "any LineRenderable that draws in world, ie in 3D perspective" + def get_projection_matrix(self): return self.app.camera.projection_matrix - + def get_view_matrix(self): return self.app.camera.view_matrix class DebugLineRenderable(WorldLineRenderable): - """ renderable for drawing debug lines in the world. use set_lines and add_lines to replace and add to, respectively, the list of 3D vertex locations (and, optionally, colors). """ - + color = (0.5, 0, 0, 1) vert_items = 3 line_width = 3 - + def set_lines(self, new_verts, new_colors=None): "replace current debug lines with new given lines" self.vert_array = np.array(new_verts, dtype=np.float32) @@ -268,20 +313,21 @@ class DebugLineRenderable(WorldLineRenderable): for i in range(1, len(new_verts)): elements += [i - 1, i] self.elem_array = np.array(elements, dtype=np.uint32) - self.color_array = np.array(new_colors or self.color * len(new_verts), - dtype=np.float32) + self.color_array = np.array( + new_colors or self.color * len(new_verts), dtype=np.float32 + ) self.rebind_buffers() - + def set_color(self, new_color): "changes all debug lines to given color" self.color = new_color lines = int(len(self.vert_array) / self.vert_items) self.color_array = np.array(self.color * lines, dtype=np.float32) self.rebind_buffers() - + def get_quad_size(self): return 1, 1 - + def add_lines(self, new_verts, new_colors=None): "add lines to the current ones" line_items = len(self.vert_array) @@ -289,7 +335,7 @@ class DebugLineRenderable(WorldLineRenderable): # if new_verts is a list of tuples, unpack into flat list if type(new_verts[0]) is tuple: new_verts_unpacked = [] - for (x, y, z) in new_verts: + for x, y, z in new_verts: new_verts_unpacked += [x, y, z] new_verts = new_verts_unpacked new_size = int(line_items + len(new_verts)) @@ -300,24 +346,27 @@ class DebugLineRenderable(WorldLineRenderable): new_elem_size = int(old_elem_size + len(new_verts) / self.vert_items) # TODO: "contiguous" parameter that joins new lines with previous self.elem_array.resize(new_elem_size) - self.elem_array[old_elem_size:new_elem_size] = range(old_elem_size, - new_elem_size) + self.elem_array[old_elem_size:new_elem_size] = range( + old_elem_size, new_elem_size + ) # grow color buffer old_color_size = len(self.color_array) new_color_size = int(old_color_size + len(new_verts) / self.vert_items * 4) self.color_array.resize(new_color_size) - self.color_array[old_color_size:new_color_size] = new_colors or self.color * int(len(new_verts) / self.vert_items) + self.color_array[old_color_size:new_color_size] = ( + new_colors or self.color * int(len(new_verts) / self.vert_items) + ) self.rebind_buffers() - + def reset_lines(self): self.build_geo() - + def build_geo(self): # start empty self.vert_array = np.array([], dtype=np.float32) self.elem_array = np.array([], dtype=np.uint32) self.color_array = np.array([], dtype=np.float32) - + def render(self): # only render if we have any data if len(self.vert_array) == 0: @@ -326,64 +375,73 @@ class DebugLineRenderable(WorldLineRenderable): class OriginIndicatorRenderable(WorldLineRenderable): - "classic 3-axis thingy showing location/rotation/scale" - - red = (1.0, 0.1, 0.1, 1.0) + + red = (1.0, 0.1, 0.1, 1.0) green = (0.1, 1.0, 0.1, 1.0) - blue = (0.1, 0.1, 1.0, 1.0) + blue = (0.1, 0.1, 1.0, 1.0) origin = (0, 0, 0) x_axis = (1, 0, 0) y_axis = (0, 1, 0) z_axis = (0, 0, 1) - vert_items = 3 + vert_items = 3 line_width = 3 use_art_offset = False - + def __init__(self, app, game_object): LineRenderable.__init__(self, app, None, game_object) - + def get_quad_size(self): return 1, 1 - + def get_size(self): return self.go.scale_x, self.go.scale_y - + def update_transform_from_object(self, obj): self.x, self.y, self.z = obj.x, obj.y, obj.z self.scale_x, self.scale_y = obj.scale_x, obj.scale_y if obj.flip_x: self.scale_x *= -1 self.scale_z = obj.scale_z - + def build_geo(self): - self.vert_array = np.array([self.origin, self.x_axis, - self.origin, self.y_axis, - self.origin, self.z_axis], - dtype=np.float32) + self.vert_array = np.array( + [ + self.origin, + self.x_axis, + self.origin, + self.y_axis, + self.origin, + self.z_axis, + ], + dtype=np.float32, + ) self.elem_array = np.array([0, 1, 2, 3, 4, 5], dtype=np.uint32) - self.color_array = np.array([self.red, self.red, self.green, self.green, - self.blue, self.blue], dtype=np.float32) + self.color_array = np.array( + [self.red, self.red, self.green, self.green, self.blue, self.blue], + dtype=np.float32, + ) + class BoundsIndicatorRenderable(WorldLineRenderable): color = (1, 1, 1, 0.5) line_width_active = 2 line_width_inactive = 1 - + def __init__(self, app, game_object): self.art = game_object.renderable.art LineRenderable.__init__(self, app, None, game_object) - + def set_art(self, new_art): self.art = new_art self.reset_size() - + def get_size(self): art = self.go.art w = (art.width * art.quad_width) * self.go.scale_x h = (art.height * art.quad_height) * self.go.scale_y return w, h - + def get_color(self): # pulse if selected if self.go in self.app.gw.selected_objects: @@ -391,33 +449,41 @@ class BoundsIndicatorRenderable(WorldLineRenderable): return (color, color, color, 1) else: return (1, 1, 1, 1) - + def get_line_width(self): - return self.line_width_active if self.go in self.app.gw.selected_objects else self.line_width_inactive - + return ( + self.line_width_active + if self.go in self.app.gw.selected_objects + else self.line_width_inactive + ) + def get_quad_size(self): if not self.go: return 1, 1 - return self.art.width * self.art.quad_width, self.art.height * self.art.quad_height - + return ( + self.art.width * self.art.quad_width, + self.art.height * self.art.quad_height, + ) + def build_geo(self): - self.vert_array, self.elem_array, self.color_array = get_box_arrays(None, self.color) + self.vert_array, self.elem_array, self.color_array = get_box_arrays( + None, self.color + ) class CollisionRenderable(WorldLineRenderable): - # green = dynamic, blue = static dynamic_color = (0, 1, 0, 1) static_color = (0, 0, 1, 1) - + def __init__(self, shape): self.color = self.dynamic_color if shape.go.is_dynamic() else self.static_color self.shape = shape WorldLineRenderable.__init__(self, shape.go.app, None, shape.go) - + def update(self): self.update_transform_from_object(self.shape) - + def update_transform_from_object(self, obj): self.x = obj.x self.y = obj.y @@ -435,19 +501,18 @@ def get_circle_points(radius, steps=24): class CircleCollisionRenderable(CollisionRenderable): - line_width = 2 segments = 24 - + def get_quad_size(self): return self.shape.radius, self.shape.radius - + def get_size(self): w = h = self.shape.radius * 2 w *= self.go.scale_x h *= self.go.scale_y return w, h - + def build_geo(self): verts, elements, colors = [], [], [] angle = 0 @@ -460,7 +525,7 @@ class CircleCollisionRenderable(CollisionRenderable): y = math.sin(angle) verts.append((x, y)) last_x, last_y = x, y - elements.append((i, i+1)) + elements.append((i, i + 1)) i += 2 colors.append([self.color * 2]) self.vert_array = np.array(verts, dtype=np.float32) @@ -469,26 +534,29 @@ class CircleCollisionRenderable(CollisionRenderable): class BoxCollisionRenderable(CollisionRenderable): - line_width = 2 - + def get_quad_size(self): return self.shape.halfwidth * 2, self.shape.halfheight * 2 - + def get_size(self): w, h = self.shape.halfwidth * 2, self.shape.halfheight * 2 w *= self.go.scale_x h *= self.go.scale_y return w, h - + def build_geo(self): verts = [(-0.5, 0.5), (0.5, 0.5), (0.5, -0.5), (-0.5, -0.5)] - self.vert_array, self.elem_array, self.color_array = get_box_arrays(verts, self.color) + self.vert_array, self.elem_array, self.color_array = get_box_arrays( + verts, self.color + ) class TileBoxCollisionRenderable(BoxCollisionRenderable): "box for each tile in a CST_TILE object" + line_width = 1 + def get_loc(self): # draw at Z level of collision layer return self.x, self.y, self.go.get_layer_z(self.go.col_layer_name) diff --git a/renderable_sprite.py b/renderable_sprite.py index 67e70d7..34e5e9c 100644 --- a/renderable_sprite.py +++ b/renderable_sprite.py @@ -1,27 +1,29 @@ -import ctypes, time -import numpy as np +import ctypes +import time +import numpy as np from OpenGL import GL from PIL import Image + from texture import Texture + class SpriteRenderable: - "basic renderable object using an image for a texture" - + vert_array = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=np.float32) - vert_shader_source = 'sprite_v.glsl' - frag_shader_source = 'sprite_f.glsl' - texture_filename = 'ui/icon.png' + vert_shader_source = "sprite_v.glsl" + frag_shader_source = "sprite_f.glsl" + texture_filename = "ui/icon.png" alpha = 1 tex_scale_x, tex_scale_y = 1, 1 blend = True flip_y = True tex_wrap = False - + def __init__(self, app, texture_filename=None, image_data=None): self.app = app - self.unique_name = '%s_%s' % (int(time.time()), self.__class__.__name__) + self.unique_name = "%s_%s" % (int(time.time()), self.__class__.__name__) self.x, self.y, self.z = self.get_initial_position() self.scale_x, self.scale_y, self.scale_z = self.get_initial_scale() if self.app.use_vao: @@ -33,62 +35,73 @@ class SpriteRenderable: self.texture_filename = texture_filename if not image_data: image_data = Image.open(self.texture_filename) - image_data = image_data.convert('RGBA') + image_data = image_data.convert("RGBA") if self.flip_y: image_data = image_data.transpose(Image.FLIP_TOP_BOTTOM) w, h = image_data.size self.texture = Texture(image_data.tobytes(), w, h) - self.shader = self.app.sl.new_shader(self.vert_shader_source, self.frag_shader_source) - self.proj_matrix_uniform = self.shader.get_uniform_location('projection') - self.view_matrix_uniform = self.shader.get_uniform_location('view') - self.position_uniform = self.shader.get_uniform_location('objectPosition') - self.scale_uniform = self.shader.get_uniform_location('objectScale') - self.tex_uniform = self.shader.get_uniform_location('texture0') - self.tex_scale_uniform = self.shader.get_uniform_location('texScale') - self.alpha_uniform = self.shader.get_uniform_location('alpha') + self.shader = self.app.sl.new_shader( + self.vert_shader_source, self.frag_shader_source + ) + self.proj_matrix_uniform = self.shader.get_uniform_location("projection") + self.view_matrix_uniform = self.shader.get_uniform_location("view") + self.position_uniform = self.shader.get_uniform_location("objectPosition") + self.scale_uniform = self.shader.get_uniform_location("objectScale") + self.tex_uniform = self.shader.get_uniform_location("texture0") + self.tex_scale_uniform = self.shader.get_uniform_location("texScale") + self.alpha_uniform = self.shader.get_uniform_location("alpha") self.vert_buffer = GL.glGenBuffers(1) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer) - GL.glBufferData(GL.GL_ARRAY_BUFFER, self.vert_array.nbytes, - self.vert_array, GL.GL_STATIC_DRAW) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, + self.vert_array.nbytes, + self.vert_array, + GL.GL_STATIC_DRAW, + ) self.vert_count = 4 - self.pos_attrib = self.shader.get_attrib_location('vertPosition') + self.pos_attrib = self.shader.get_attrib_location("vertPosition") GL.glEnableVertexAttribArray(self.pos_attrib) offset = ctypes.c_void_p(0) - GL.glVertexAttribPointer(self.pos_attrib, 2, - GL.GL_FLOAT, GL.GL_FALSE, 0, offset) + GL.glVertexAttribPointer( + self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, offset + ) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) if self.app.use_vao: GL.glBindVertexArray(0) self.texture.set_wrap(self.tex_wrap) - + def get_initial_position(self): return 0, 0, 0 - + def get_initial_scale(self): return 1, 1, 1 - + def get_projection_matrix(self): return self.app.camera.projection_matrix - + def get_view_matrix(self): return self.app.camera.view_matrix - + def get_texture_scale(self): return self.tex_scale_x, self.tex_scale_y - + def destroy(self): if self.app.use_vao: GL.glDeleteVertexArrays(1, [self.vao]) GL.glDeleteBuffers(1, [self.vert_buffer]) - + def render(self): GL.glUseProgram(self.shader.program) GL.glActiveTexture(GL.GL_TEXTURE0) GL.glUniform1i(self.tex_uniform, 0) GL.glUniform2f(self.tex_scale_uniform, *self.get_texture_scale()) GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture.gltex) - GL.glUniformMatrix4fv(self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix()) - GL.glUniformMatrix4fv(self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix()) + GL.glUniformMatrix4fv( + self.proj_matrix_uniform, 1, GL.GL_FALSE, self.get_projection_matrix() + ) + GL.glUniformMatrix4fv( + self.view_matrix_uniform, 1, GL.GL_FALSE, self.get_view_matrix() + ) GL.glUniform3f(self.position_uniform, self.x, self.y, self.z) GL.glUniform3f(self.scale_uniform, self.scale_x, self.scale_y, self.scale_z) GL.glUniform1f(self.alpha_uniform, self.alpha) @@ -96,8 +109,9 @@ class SpriteRenderable: GL.glBindVertexArray(self.vao) else: GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vert_buffer) - GL.glVertexAttribPointer(self.pos_attrib, 2, - GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0)) + GL.glVertexAttribPointer( + self.pos_attrib, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, ctypes.c_void_p(0) + ) GL.glEnableVertexAttribArray(self.pos_attrib) if self.blend: GL.glEnable(GL.GL_BLEND) @@ -111,10 +125,9 @@ class SpriteRenderable: class UISpriteRenderable(SpriteRenderable): - def get_projection_matrix(self): return self.app.ui.view_matrix - + def get_view_matrix(self): return self.app.ui.view_matrix @@ -122,11 +135,11 @@ class UISpriteRenderable(SpriteRenderable): class UIBGTextureRenderable(UISpriteRenderable): alpha = 0.8 tex_wrap = True - texture_filename = 'ui/bgnoise_alpha.png' + texture_filename = "ui/bgnoise_alpha.png" tex_scale_x, tex_scale_y = 8, 8 - + def get_initial_position(self): return -1, -1, 0 - + def get_initial_scale(self): return 2, 2, 1 diff --git a/selection.py b/selection.py index e2add0e..1887639 100644 --- a/selection.py +++ b/selection.py @@ -1,24 +1,25 @@ import math + import numpy as np from renderable_line import LineRenderable + class SelectionRenderable(LineRenderable): - color = (0.8, 0.8, 0.8, 1) line_width = 2 x, y, z = 0, 0, 0 - + def build_geo(self): # init empty arrays; geo is rebuilt every time selection changes self.vert_array = np.array([], dtype=np.float32) self.elem_array = np.array([], dtype=np.uint32) self.color_array = np.array([], dtype=np.float32) - + def get_adjacent_tile(self, tiles, x, y, dir_x, dir_y): "returns True or False based on tile dict lookup relative to given tile" return tiles.get((x + dir_x, y + dir_y), False) - + def rebuild_geo(self, tiles): # array source lists of verts, elements, colors v, e, c = [], [], [] @@ -31,14 +32,16 @@ class SelectionRenderable(LineRenderable): below = self.get_adjacent_tile(tiles, x, y, 0, 1) left = self.get_adjacent_tile(tiles, x, y, -1, 0) right = self.get_adjacent_tile(tiles, x, y, 1, 0) - top_left = ( x, -y) - top_right = (x+1, -y) - bottom_right = (x+1, -y-1) - bottom_left = ( x, -y-1) + top_left = (x, -y) + top_right = (x + 1, -y) + bottom_right = (x + 1, -y - 1) + bottom_left = (x, -y - 1) + def add_line(vert_a, vert_b, verts, elems, colors, element_index): verts += [vert_a, vert_b] - elems += [element_index, element_index+1] + elems += [element_index, element_index + 1] colors += self.color * 2 + # verts = corners if not above: # top edge @@ -60,16 +63,16 @@ class SelectionRenderable(LineRenderable): self.vert_array = np.array(v, dtype=np.float32) self.elem_array = np.array(e, dtype=np.uint32) self.color_array = np.array(c, dtype=np.float32) - + def reset_loc(self): pass - + def get_projection_matrix(self): return self.app.camera.projection_matrix - + def get_view_matrix(self): return self.app.camera.view_matrix - + def get_color(self): # pulse for visibility a = 0.75 + (math.sin(self.app.get_elapsed_time() / 100) / 2) diff --git a/shader.py b/shader.py index 436c632..d21ed3e 100644 --- a/shader.py +++ b/shader.py @@ -1,31 +1,40 @@ -import os.path, time, platform +import os.path +import platform +import time + from OpenGL import GL from OpenGL.GL import shaders -SHADER_PATH = 'shaders/' +SHADER_PATH = "shaders/" + class ShaderLord: - # time in ms between checks for hot reload hot_reload_check_interval = 2 * 1000 - + def __init__(self, app): "AWAKENS THE SHADERLORD" self.app = app self.shaders = [] - + def new_shader(self, vert_source_file, frag_source_file): self.last_check = 0 for shader in self.shaders: - if shader.vert_source_file == vert_source_file and shader.frag_source_file == frag_source_file: - #self.app.log('%s already uses same source' % shader) + if ( + shader.vert_source_file == vert_source_file + and shader.frag_source_file == frag_source_file + ): + # self.app.log('%s already uses same source' % shader) return shader s = Shader(self, vert_source_file, frag_source_file) self.shaders.append(s) return s - + def check_hot_reload(self): - if self.app.get_elapsed_time() - self.last_check < self.hot_reload_check_interval: + if ( + self.app.get_elapsed_time() - self.last_check + < self.hot_reload_check_interval + ): return self.last_check = self.app.get_elapsed_time() for shader in self.shaders: @@ -34,14 +43,13 @@ class ShaderLord: shader.recompile(GL.GL_VERTEX_SHADER) if frag_shader_updated: shader.recompile(GL.GL_FRAGMENT_SHADER) - + def destroy(self): for shader in self.shaders: shader.destroy() class Shader: - log_compile = False "If True, log shader compilation" # per-platform shader versions, declared here for easier CFG fiddling @@ -49,7 +57,7 @@ class Shader: glsl_version_unix = 130 glsl_version_macos = 150 glsl_version_es = 100 - + def __init__(self, shader_lord, vert_source_file, frag_source_file): self.sl = shader_lord # vertex shader @@ -57,52 +65,62 @@ class Shader: self.last_vert_change = time.time() vert_source = self.get_shader_source(self.vert_source_file) if self.log_compile: - self.sl.app.log('Compiling vertex shader %s...' % self.vert_source_file) - self.vert_shader = self.try_compile_shader(vert_source, GL.GL_VERTEX_SHADER, self.vert_source_file) + self.sl.app.log("Compiling vertex shader %s..." % self.vert_source_file) + self.vert_shader = self.try_compile_shader( + vert_source, GL.GL_VERTEX_SHADER, self.vert_source_file + ) if self.log_compile and self.vert_shader: - self.sl.app.log('Compiled vertex shader %s in %.6f seconds' % (self.vert_source_file, time.time() - self.last_vert_change)) + self.sl.app.log( + "Compiled vertex shader %s in %.6f seconds" + % (self.vert_source_file, time.time() - self.last_vert_change) + ) # fragment shader self.frag_source_file = frag_source_file self.last_frag_change = time.time() frag_source = self.get_shader_source(self.frag_source_file) if self.log_compile: - self.sl.app.log('Compiling fragment shader %s...' % self.frag_source_file) - self.frag_shader = self.try_compile_shader(frag_source, GL.GL_FRAGMENT_SHADER, self.frag_source_file) + self.sl.app.log("Compiling fragment shader %s..." % self.frag_source_file) + self.frag_shader = self.try_compile_shader( + frag_source, GL.GL_FRAGMENT_SHADER, self.frag_source_file + ) if self.log_compile and self.frag_shader: - self.sl.app.log('Compiled fragment shader %s in %.6f seconds' % (self.frag_source_file, time.time() - self.last_frag_change)) + self.sl.app.log( + "Compiled fragment shader %s in %.6f seconds" + % (self.frag_source_file, time.time() - self.last_frag_change) + ) # shader program if self.vert_shader and self.frag_shader: self.program = shaders.compileProgram(self.vert_shader, self.frag_shader) - + def get_shader_source(self, source_file): - src = open(SHADER_PATH + source_file, 'rb').read() + src = open(SHADER_PATH + source_file, "rb").read() # prepend shader version for different platforms if self.sl.app.context_es: shader_version = self.glsl_version_es - elif platform.system() == 'Windows': + elif platform.system() == "Windows": shader_version = self.glsl_version_windows - elif platform.system() == 'Darwin': + elif platform.system() == "Darwin": shader_version = self.glsl_version_macos else: shader_version = self.glsl_version_unix - version_string = '#version %s\n' % shader_version - src = bytes(version_string, 'utf-8') + src + version_string = "#version %s\n" % shader_version + src = bytes(version_string, "utf-8") + src return src - + def try_compile_shader(self, source, shader_type, source_filename): "Catch and print shader compilation exceptions" try: shader = shaders.compileShader(source, shader_type) except Exception as e: - self.sl.app.log('%s: ' % source_filename) - lines = e.args[0].split('\\n') + self.sl.app.log("%s: " % source_filename) + lines = e.args[0].split("\\n") # salvage block after "shader compile failure" enclosed in b"" pre = lines.pop(0).split('b"') for line in pre + lines[:-1]: - self.sl.app.log(' ' + line) + self.sl.app.log(" " + line) return return shader - + def has_updated(self): vert_mod_time = os.path.getmtime(SHADER_PATH + self.vert_source_file) frag_mod_time = os.path.getmtime(SHADER_PATH + self.frag_source_file) @@ -115,7 +133,7 @@ class Shader: if frag_changed: self.last_frag_change = time.time() return vert_changed, frag_changed - + def recompile(self, shader_type): file_to_reload = self.vert_source_file if shader_type == GL.GL_FRAGMENT_SHADER: @@ -124,9 +142,9 @@ class Shader: try: new_shader = shaders.compileShader(new_shader_source, shader_type) # TODO: use try_compile_shader instead here, make sure exception passes thru ok - self.sl.app.log('ShaderLord: success reloading %s' % file_to_reload) + self.sl.app.log("ShaderLord: success reloading %s" % file_to_reload) except: - self.sl.app.log('ShaderLord: failed reloading %s' % file_to_reload) + self.sl.app.log("ShaderLord: failed reloading %s" % file_to_reload) return # recompile program with new shader if shader_type == GL.GL_VERTEX_SHADER: @@ -134,13 +152,13 @@ class Shader: else: self.frag_shader = new_shader self.program = shaders.compileProgram(self.vert_shader, self.frag_shader) - + def get_uniform_location(self, uniform_name): return GL.glGetUniformLocation(self.program, uniform_name) - + def get_attrib_location(self, attrib_name): return GL.glGetAttribLocation(self.program, attrib_name) - + def destroy(self): GL.glDeleteProgram(self.program) diff --git a/texture.py b/texture.py index 24b5f34..cee30eb 100644 --- a/texture.py +++ b/texture.py @@ -1,15 +1,15 @@ import numpy as np from OpenGL import GL + class Texture: - # TODO: move texture data init to a set method to make hot reload trivial(?) - + mag_filter = GL.GL_NEAREST min_filter = GL.GL_NEAREST - #min_filter = GL.GL_NEAREST_MIPMAP_NEAREST + # min_filter = GL.GL_NEAREST_MIPMAP_NEAREST packing = GL.GL_UNPACK_ALIGNMENT - + def __init__(self, data, width, height): self.width, self.height = width, height img_data = np.frombuffer(data, dtype=np.uint8) @@ -18,23 +18,32 @@ class Texture: GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex) self.set_filter(self.mag_filter, self.min_filter, False) self.set_wrap(False, False) - GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA, width, height, 0, - GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, img_data) + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + 0, + GL.GL_RGBA, + width, + height, + 0, + GL.GL_RGBA, + GL.GL_UNSIGNED_BYTE, + img_data, + ) if bool(GL.glGenerateMipmap): GL.glGenerateMipmap(GL.GL_TEXTURE_2D) - + def set_filter(self, new_mag_filter, new_min_filter, bind_first=True): if bind_first: GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex) GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, new_mag_filter) GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, new_min_filter) - + def set_wrap(self, new_wrap, bind_first=True): if bind_first: GL.glBindTexture(GL.GL_TEXTURE_2D, self.gltex) wrap = GL.GL_REPEAT if new_wrap else GL.GL_CLAMP_TO_EDGE GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, wrap) GL.glTexParameterf(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, wrap) - + def destroy(self): GL.glDeleteTextures([self.gltex]) diff --git a/ui.py b/ui.py index 9e14538..c606954 100644 --- a/ui.py +++ b/ui.py @@ -1,24 +1,45 @@ -import sdl2 import numpy as np -from PIL import Image +import sdl2 from OpenGL import GL +from PIL import Image +from art import ( + UV_FLIP270, + UV_NORMAL, + uv_names, +) +from edit_command import EditCommand, EditCommandTile, EntireArtCommand from texture import Texture -from ui_element import UIArt, FPSCounterUI, MessageLineUI, DebugTextUI, GameSelectionLabel, GameHoverLabel, ToolTip +from ui_colors import UIColors from ui_console import ConsoleUI -from ui_status_bar import StatusBarUI -from ui_popup import ToolPopup +from ui_edit_panel import EditListPanel +from ui_element import ( + DebugTextUI, + FPSCounterUI, + GameHoverLabel, + GameSelectionLabel, + MessageLineUI, + ToolTip, + UIArt, +) from ui_menu_bar import ArtMenuBar, GameMenuBar from ui_menu_pulldown import PulldownMenu -from ui_edit_panel import EditListPanel from ui_object_panel import EditObjectPanel -from ui_colors import UIColors -from ui_tool import PencilTool, EraseTool, GrabTool, RotateTool, TextTool, SelectTool, PasteTool, FillTool +from ui_popup import ToolPopup +from ui_status_bar import StatusBarUI +from ui_tool import ( + EraseTool, + FillTool, + GrabTool, + PasteTool, + PencilTool, + RotateTool, + SelectTool, + TextTool, +) from ui_toolbar import ArtToolBar -from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY, UV_FLIP90, UV_FLIP270, uv_names -from edit_command import EditCommand, EditCommandTile, EntireArtCommand -UI_ASSET_DIR = 'ui/' +UI_ASSET_DIR = "ui/" SCALE_INCREMENT = 0.25 # spacing factor of each non-active document's scale from active document MDI_MARGIN = 1.1 @@ -30,40 +51,47 @@ OIS_FILL = 2 class UI: - # user-configured UI scale factor scale = 1.0 max_onion_alpha = 0.5 - charset_name = 'ui' - palette_name = 'c64_original' + charset_name = "ui" + palette_name = "c64_original" # red color for warnings error_color_index = UIColors.brightred # low-contrast background texture that distinguishes UI from flat color - grain_texture_path = UI_ASSET_DIR + 'bgnoise_alpha.png' + grain_texture_path = UI_ASSET_DIR + "bgnoise_alpha.png" # expose to classes that don't want to import this module asset_dir = UI_ASSET_DIR visible = True logg = False popup_hold_to_show = False flip_affects_xforms = True - tool_classes = [ PencilTool, EraseTool, GrabTool, RotateTool, TextTool, - SelectTool, PasteTool, FillTool ] - tool_selected_log = 'tool selected' - art_selected_log = 'Now editing' - frame_selected_log = 'Now editing frame %s (hold time %ss)' - layer_selected_log = 'Now editing layer: %s' - swap_color_log = 'Swapped FG/BG colors' - affects_char_on_log = 'will affect characters' - affects_char_off_log = 'will not affect characters' - affects_fg_on_log = 'will affect foreground colors' - affects_fg_off_log = 'will not affect foreground colors' - affects_bg_on_log = 'will affect background colors' - affects_bg_off_log = 'will not affect background colors' - affects_xform_on_log = 'will affect character rotation/flip' - affects_xform_off_log = 'will not affect character rotation/flip' - xform_selected_log = 'Selected character transform:' - show_edit_ui_log = 'Edit UI hidden, press %s to unhide.' - + tool_classes = [ + PencilTool, + EraseTool, + GrabTool, + RotateTool, + TextTool, + SelectTool, + PasteTool, + FillTool, + ] + tool_selected_log = "tool selected" + art_selected_log = "Now editing" + frame_selected_log = "Now editing frame %s (hold time %ss)" + layer_selected_log = "Now editing layer: %s" + swap_color_log = "Swapped FG/BG colors" + affects_char_on_log = "will affect characters" + affects_char_off_log = "will not affect characters" + affects_fg_on_log = "will affect foreground colors" + affects_fg_off_log = "will not affect foreground colors" + affects_bg_on_log = "will affect background colors" + affects_bg_off_log = "will not affect background colors" + affects_xform_on_log = "will affect character rotation/flip" + affects_xform_off_log = "will not affect character rotation/flip" + xform_selected_log = "Selected character transform:" + show_edit_ui_log = "Edit UI hidden, press %s to unhide." + def __init__(self, app, active_art): self.app = app # the current art being edited @@ -92,7 +120,7 @@ class UI: # create tools for t in self.tool_classes: new_tool = t(self) - tool_name = '%s_tool' % new_tool.name + tool_name = "%s_tool" % new_tool.name setattr(self, tool_name, new_tool) # stick in a list for popup tool tab self.tools.append(new_tool) @@ -125,17 +153,27 @@ class UI: self.edit_object_panel = EditObjectPanel(self) self.game_selection_label = GameSelectionLabel(self) self.game_hover_label = GameHoverLabel(self) - self.elements += [self.fps_counter, self.status_bar, self.popup, - self.message_line, self.debug_text, self.pulldown, - self.art_menu_bar, self.game_menu_bar, self.tooltip, - self.art_toolbar, - self.edit_list_panel, self.edit_object_panel, - self.game_hover_label, self.game_selection_label] + self.elements += [ + self.fps_counter, + self.status_bar, + self.popup, + self.message_line, + self.debug_text, + self.pulldown, + self.art_menu_bar, + self.game_menu_bar, + self.tooltip, + self.art_toolbar, + self.edit_list_panel, + self.edit_object_panel, + self.game_hover_label, + self.game_selection_label, + ] # add console last so it draws last self.elements.append(self.console) # grain texture img = Image.open(self.grain_texture_path) - img = img.convert('RGBA') + img = img.convert("RGBA") width, height = img.size self.grain_texture = Texture(img.tobytes(), width, height) self.grain_texture.set_wrap(True) @@ -145,7 +183,7 @@ class UI: # if editing is disallowed, hide game mode UI if not self.app.can_edit: self.set_game_edit_ui_visibility(False) - + def set_scale(self, new_scale): old_scale = self.scale self.scale = new_scale @@ -155,8 +193,12 @@ class UI: aspect = float(self.app.window_width) / self.app.window_height inv_aspect = float(self.app.window_height) / self.app.window_width # MAYBE-TODO: this math is correct but hard to follow, rewrite for clarity - width = self.app.window_width / (self.charset.char_width * self.scale * inv_aspect) - height = self.app.window_height / (self.charset.char_height * self.scale * inv_aspect) + width = self.app.window_width / ( + self.charset.char_width * self.scale * inv_aspect + ) + height = self.app.window_height / ( + self.charset.char_height * self.scale * inv_aspect + ) # any new UI elements created should use new scale UIArt.quad_width = 2 / width * aspect UIArt.quad_height = 2 / height * aspect @@ -165,8 +207,11 @@ class UI: # tell elements to refresh self.set_elements_scale() if self.scale != old_scale: - self.message_line.post_line('UI scale is now %s (%.3f x %.3f)' % (self.scale, self.width_tiles, self.height_tiles)) - + self.message_line.post_line( + "UI scale is now %s (%.3f x %.3f)" + % (self.scale, self.width_tiles, self.height_tiles) + ) + def set_elements_scale(self): for e in self.elements: e.art.quad_width, e.art.quad_height = UIArt.quad_width, UIArt.quad_height @@ -174,11 +219,11 @@ class UI: e.reset_art() e.reset_loc() e.art.geo_changed = True - + def window_resized(self): # recalc renderables' quad size (same scale, different aspect) self.set_scale(self.scale) - + def size_and_position_overlay_image(self): # called any time active art changes, or active art changes size r = self.app.overlay_renderable @@ -197,7 +242,7 @@ class UI: r.scale_y = self.active_art.height * self.active_art.quad_height r.y = -r.scale_y r.z = self.active_art.layers_z[self.active_art.active_layer] - + def set_active_art(self, new_art): self.active_art = new_art new_charset = self.active_art.charset @@ -231,16 +276,17 @@ class UI: # rescale/reposition overlay image self.size_and_position_overlay_image() # tell select tool renderables - for r in [self.select_tool.select_renderable, - self.select_tool.drag_renderable]: + for r in [self.select_tool.select_renderable, self.select_tool.drag_renderable]: r.quad_size_ref = new_art r.rebuild_geo(self.select_tool.selected_tiles) self.app.update_window_title() if self.app.can_edit: - self.message_line.post_line('%s %s' % (self.art_selected_log, self.active_art.filename)) - + self.message_line.post_line( + "%s %s" % (self.art_selected_log, self.active_art.filename) + ) + def set_active_art_by_filename(self, art_filename): - for i,art in enumerate(self.app.art_loaded_for_edit): + for i, art in enumerate(self.app.art_loaded_for_edit): if art_filename == art.filename: break new_active_art = self.app.art_loaded_for_edit.pop(i) @@ -248,7 +294,7 @@ class UI: new_active_renderable = self.app.edit_renderables.pop(i) self.app.edit_renderables.insert(0, new_active_renderable) self.set_active_art(new_active_art) - + def previous_active_art(self): "cycles to next art in app.art_loaded_for_edit" if len(self.app.art_loaded_for_edit) == 1: @@ -258,7 +304,7 @@ class UI: next_active_renderable = self.app.edit_renderables.pop(-1) self.app.edit_renderables.insert(0, next_active_renderable) self.set_active_art(self.app.art_loaded_for_edit[0]) - + def next_active_art(self): if len(self.app.art_loaded_for_edit) == 1: return @@ -267,12 +313,12 @@ class UI: last_active_renderable = self.app.edit_renderables.pop(0) self.app.edit_renderables.append(last_active_renderable) self.set_active_art(self.app.art_loaded_for_edit[0]) - + def set_selected_tool(self, new_tool): if self.app.game_mode: return # don't re-select same tool, except to cycle fill tool (see below) - if new_tool == self.selected_tool and not type(new_tool) is FillTool: + if new_tool == self.selected_tool and type(new_tool) is not FillTool: return # bail out of text entry if active if self.selected_tool is self.text_tool: @@ -285,20 +331,26 @@ class UI: # if we're selecting the fill tool and it's already selected, # cycle through its 3 modes (char/fg/bg boundary) cycled_fill = False - if type(self.selected_tool) is FillTool and \ - type(self.previous_tool) is FillTool: - self.selected_tool.boundary_mode = self.selected_tool.next_boundary_modes[self.selected_tool.boundary_mode] + if ( + type(self.selected_tool) is FillTool + and type(self.previous_tool) is FillTool + ): + self.selected_tool.boundary_mode = self.selected_tool.next_boundary_modes[ + self.selected_tool.boundary_mode + ] # TODO: do we need a message line message for this? - #self.app.log(self.selected_tool.boundary_mode) + # self.app.log(self.selected_tool.boundary_mode) cycled_fill = True # close menu if we selected tool from it if self.menu_bar.active_menu_name and not cycled_fill: self.menu_bar.close_active_menu() - self.message_line.post_line('%s %s' % (self.selected_tool.get_button_caption(), self.tool_selected_log)) - + self.message_line.post_line( + "%s %s" % (self.selected_tool.get_button_caption(), self.tool_selected_log) + ) + def cycle_fill_tool_mode(self): self.set_selected_tool(self.fill_tool) - + def get_longest_tool_name_length(self): "VERY specific function to help status bar draw its buttons" longest = 0 @@ -306,7 +358,7 @@ class UI: if len(tool.button_caption) > longest: longest = len(tool.button_caption) return longest - + def cycle_selected_tool(self, back=False): if not self.active_art: return @@ -317,16 +369,17 @@ class UI: tool_index += 1 tool_index %= len(self.tools) self.set_selected_tool(self.tools[tool_index]) - + def set_selected_xform(self, new_xform): self.selected_xform = new_xform self.popup.set_xform(new_xform) self.tool_settings_changed = True - line = '%s %s' % (self.xform_selected_log, uv_names[self.selected_xform]) + line = "%s %s" % (self.xform_selected_log, uv_names[self.selected_xform]) self.message_line.post_line(line) - + def cycle_selected_xform(self, back=False): - if self.app.game_mode: return + if self.app.game_mode: + return xform = self.selected_xform if back: xform -= 1 @@ -334,40 +387,42 @@ class UI: xform += 1 xform %= UV_FLIP270 + 1 self.set_selected_xform(xform) - + def reset_onion_frames(self, new_art=None): "set correct visibility, frame, and alpha for all onion renderables" new_art = new_art or self.active_art alpha = self.max_onion_alpha total_onion_frames = 0 + def set_onion(r, new_frame, alpha): # scale back if fewer than MAX_ONION_FRAMES in either direction if total_onion_frames >= new_art.frames: r.visible = False return r.visible = True - if not new_art is r.art: + if new_art is not r.art: r.set_art(new_art) r.set_frame(new_frame) r.alpha = alpha # make BG dimmer so it's easier to see r.bg_alpha = alpha / 2 + # populate "next" frames first - for i,r in enumerate(self.app.onion_renderables_next): + for i, r in enumerate(self.app.onion_renderables_next): total_onion_frames += 1 new_frame = new_art.active_frame + i + 1 set_onion(r, new_frame, alpha) alpha /= 2 - #print('next onion %s set to frame %s alpha %s' % (i, new_frame, alpha)) + # print('next onion %s set to frame %s alpha %s' % (i, new_frame, alpha)) alpha = self.max_onion_alpha - for i,r in enumerate(self.app.onion_renderables_prev): + for i, r in enumerate(self.app.onion_renderables_prev): total_onion_frames += 1 new_frame = new_art.active_frame - (i + 1) set_onion(r, new_frame, alpha) # each successive onion layer is dimmer alpha /= 2 - #print('previous onion %s set to frame %s alpha %s' % (i, new_frame, alpha)) - + # print('previous onion %s set to frame %s alpha %s' % (i, new_frame, alpha)) + def set_active_frame(self, new_frame): if not self.active_art.set_active_frame(new_frame): return @@ -377,7 +432,7 @@ class UI: delay = self.active_art.frame_delays[frame] if self.app.can_edit: self.message_line.post_line(self.frame_selected_log % (frame + 1, delay)) - + def set_active_layer(self, new_layer): self.active_art.set_active_layer(new_layer) z = self.active_art.layers_z[self.active_art.active_layer] @@ -391,20 +446,22 @@ class UI: if self.app.can_edit: self.message_line.post_line(self.layer_selected_log % layer_name) self.size_and_position_overlay_image() - + def select_char(self, new_char_index): if not self.active_art: return # wrap at last valid index self.selected_char = new_char_index % self.active_art.charset.last_index # only update char tooltip if it was already up; avoid stomping others - char_cycle_button = self.status_bar.button_map['char_cycle'] - char_toggle_button = self.status_bar.button_map['char_toggle'] - if char_cycle_button in self.status_bar.hovered_buttons or \ - char_toggle_button in self.status_bar.hovered_buttons: + char_cycle_button = self.status_bar.button_map["char_cycle"] + char_toggle_button = self.status_bar.button_map["char_toggle"] + if ( + char_cycle_button in self.status_bar.hovered_buttons + or char_toggle_button in self.status_bar.hovered_buttons + ): char_toggle_button.update_tooltip() self.tool_settings_changed = True - + def select_color(self, new_color_index, fg): "common code for select_fg/bg" if not self.active_art: @@ -415,19 +472,29 @@ class UI: else: self.selected_bg_color = new_color_index # same don't-stomp-another-tooltip check as above - toggle_button = self.status_bar.button_map['fg_toggle'] if fg else self.status_bar.button_map['bg_toggle'] - cycle_button = self.status_bar.button_map['fg_cycle'] if fg else self.status_bar.button_map['bg_cycle'] - if toggle_button in self.status_bar.hovered_buttons or \ - cycle_button in self.status_bar.hovered_buttons: + toggle_button = ( + self.status_bar.button_map["fg_toggle"] + if fg + else self.status_bar.button_map["bg_toggle"] + ) + cycle_button = ( + self.status_bar.button_map["fg_cycle"] + if fg + else self.status_bar.button_map["bg_cycle"] + ) + if ( + toggle_button in self.status_bar.hovered_buttons + or cycle_button in self.status_bar.hovered_buttons + ): toggle_button.update_tooltip() self.tool_settings_changed = True - + def select_fg(self, new_fg_index): self.select_color(new_fg_index, True) - + def select_bg(self, new_bg_index): self.select_color(new_bg_index, False) - + def swap_fg_bg_colors(self): if self.app.game_mode: return @@ -435,11 +502,11 @@ class UI: self.selected_fg_color, self.selected_bg_color = bg, fg self.tool_settings_changed = True self.message_line.post_line(self.swap_color_log) - + def cut_selection(self): self.copy_selection() self.erase_tiles_in_selection() - + def erase_selection_or_art(self): if len(self.select_tool.selected_tiles) > 0: self.erase_tiles_in_selection() @@ -447,7 +514,7 @@ class UI: self.select_all() self.erase_tiles_in_selection() self.select_none() - + def erase_tiles_in_selection(self): # create and commit command group to clear all tiles in selection frame, layer = self.active_art.active_frame, self.active_art.active_layer @@ -455,7 +522,9 @@ class UI: for tile in self.select_tool.selected_tiles: new_tile_command = EditCommandTile(self.active_art) new_tile_command.set_tile(frame, layer, *tile) - b_char, b_fg, b_bg, b_xform = self.active_art.get_tile_at(frame, layer, *tile) + b_char, b_fg, b_bg, b_xform = self.active_art.get_tile_at( + frame, layer, *tile + ) new_tile_command.set_before(b_char, b_fg, b_bg, b_xform) a_char = a_fg = 0 a_xform = UV_NORMAL @@ -466,7 +535,7 @@ class UI: new_command.apply() self.active_art.command_stack.commit_commands([new_command]) self.active_art.set_unsaved_changes(True) - + def copy_selection(self): # convert current selection tiles (active frame+layer) into # EditCommandTiles for Cursor.preview_edits @@ -499,7 +568,7 @@ class UI: tile_command.set_tile(frame, layer, x, y) self.clipboard_width = max_x - min_x self.clipboard_height = max_y - min_y - + def crop_to_selection(self, art): # ignore non-rectangular selection features, use top left and bottom # right corners @@ -523,7 +592,7 @@ class UI: command = EntireArtCommand(art, min_x, min_y) command.save_tiles(before=True) art.resize(w, h, min_x, min_y) - self.app.log('Resized %s to %s x %s' % (art.filename, w, h)) + self.app.log("Resized %s to %s x %s" % (art.filename, w, h)) art.set_unsaved_changes(True) # clear selection to avoid having tiles we know are OoB selected self.select_tool.selected_tiles = {} @@ -531,11 +600,11 @@ class UI: # commit command command.save_tiles(before=False) art.command_stack.commit_commands([command]) - + def reset_edit_renderables(self): # reposition all art renderables and change their opacity x, y = 0, 0 - for i,r in enumerate(self.app.edit_renderables): + for i, r in enumerate(self.app.edit_renderables): # always put active art at 0,0 if r in self.active_art.renderables: r.alpha = 1 @@ -553,7 +622,7 @@ class UI: r.move_to(x * MDI_MARGIN, 0, -i, 0.2) x += r.art.width * r.art.quad_width y -= r.art.height * r.art.quad_height - + def adjust_for_art_resize(self, art): if art is not self.active_art: return @@ -568,7 +637,7 @@ class UI: if self.app.cursor.y > art.height: self.app.cursor.y = art.height self.app.cursor.moved = True - + def resize_art(self, art, new_width, new_height, origin_x, origin_y, bg_fill): # create command for undo/redo command = EntireArtCommand(art, origin_x, origin_y) @@ -580,16 +649,16 @@ class UI: command.save_tiles(before=False) art.command_stack.commit_commands([command]) art.set_unsaved_changes(True) - + def select_none(self): self.select_tool.selected_tiles = {} - + def select_all(self): self.select_tool.selected_tiles = {} for y in range(self.active_art.height): for x in range(self.active_art.width): self.select_tool.selected_tiles[(x, y)] = True - + def invert_selection(self): old_selection = self.select_tool.selected_tiles.copy() self.select_tool.selected_tiles = {} @@ -597,12 +666,12 @@ class UI: for x in range(self.active_art.width): if not old_selection.get((x, y), False): self.select_tool.selected_tiles[(x, y)] = True - + def get_screen_coords(self, window_x, window_y): x = (2 * window_x) / self.app.window_width - 1 y = (-2 * window_y) / self.app.window_height + 1 return x, y - + def update(self): self.select_tool.update() # window coordinates -> OpenGL coordinates @@ -615,14 +684,19 @@ class UI: if self.console.visible: continue # only check visible elements - if self.app.has_mouse_focus and e.is_visible() and e.can_hover and e.is_inside(mx, my): + if ( + self.app.has_mouse_focus + and e.is_visible() + and e.can_hover + and e.is_inside(mx, my) + ): self.hovered_elements.append(e) # only hover if we weren't last update - if not e in was_hovering: + if e not in was_hovering: e.hovered() for e in was_hovering: # unhover if app window loses mouse focus - if not self.app.has_mouse_focus or not e in self.hovered_elements: + if not self.app.has_mouse_focus or e not in self.hovered_elements: e.unhovered() # update all elements, regardless of whether they're being hovered etc for e in self.elements: @@ -632,7 +706,7 @@ class UI: # art update: tell renderables to refresh buffers e.art.update() self.tool_settings_changed = False - + def clicked(self, mouse_button): handled = False # return True if any button handled the input @@ -642,17 +716,21 @@ class UI: if e.clicked(mouse_button): handled = True # close pulldown if clicking outside it / the menu bar - if self.pulldown.visible and not self.pulldown in self.hovered_elements and not self.menu_bar in self.hovered_elements: + if ( + self.pulldown.visible + and self.pulldown not in self.hovered_elements + and self.menu_bar not in self.hovered_elements + ): self.menu_bar.close_active_menu() return handled - + def unclicked(self, mouse_button): handled = False for e in self.hovered_elements: if e.unclicked(mouse_button): handled = True return handled - + def wheel_moved(self, wheel_y): handled = False # use wheel to scroll chooser dialogs @@ -660,17 +738,19 @@ class UI: # an SDL keycode from that? if self.active_dialog: keycode = sdl2.SDLK_UP if wheel_y > 0 else sdl2.SDLK_DOWN - self.active_dialog.handle_input(keycode, - self.app.il.shift_pressed, - self.app.il.alt_pressed, - self.app.il.ctrl_pressed) + self.active_dialog.handle_input( + keycode, + self.app.il.shift_pressed, + self.app.il.alt_pressed, + self.app.il.ctrl_pressed, + ) handled = True elif len(self.hovered_elements) > 0: for e in self.hovered_elements: if e.wheel_moved(wheel_y): handled = True return handled - + def quick_grab(self): if self.app.game_mode: return @@ -678,17 +758,17 @@ class UI: return self.grab_tool.grab() self.tool_settings_changed = True - + def undo(self): # if still painting, finish if self.app.cursor.current_command: self.app.cursor.finish_paint() self.active_art.command_stack.undo() self.active_art.set_unsaved_changes(True) - + def redo(self): self.active_art.command_stack.redo() - + def open_dialog(self, dialog_class, options={}): if self.app.game_mode and not dialog_class.game_mode_visible: return @@ -696,14 +776,14 @@ class UI: self.active_dialog = dialog self.keyboard_focus_element = self.active_dialog # insert dialog at index 0 so it draws first instead of last - #self.elements.insert(0, dialog) + # self.elements.insert(0, dialog) self.elements.remove(self.console) self.elements.append(dialog) self.elements.append(self.console) - + def is_game_edit_ui_visible(self): return self.game_menu_bar.visible - + def set_game_edit_ui_visibility(self, visible, show_message=True): self.game_menu_bar.visible = visible self.edit_list_panel.visible = visible @@ -712,24 +792,26 @@ class UI: # relinquish keyboard focus in play mode self.keyboard_focus_element = None if show_message and self.app.il: - bind = self.app.il.get_command_shortcut('toggle_game_edit_ui') + bind = self.app.il.get_command_shortcut("toggle_game_edit_ui") bind = bind.title() self.message_line.post_line(self.show_edit_ui_log % bind, 10) else: - self.message_line.post_line('') + self.message_line.post_line("") self.app.update_window_title() - + def object_selection_changed(self): if len(self.app.gw.selected_objects) == 0: self.keyboard_focus_element = None self.refocus_keyboard() - + def switch_edit_panel_focus(self, reverse=False): # only allow tabbing away if list panel is in allowed mode lp = self.edit_list_panel - if self.keyboard_focus_element is lp and \ - lp.list_operation in lp.list_operations_allow_kb_focus and \ - self.active_dialog: + if ( + self.keyboard_focus_element is lp + and lp.list_operation in lp.list_operations_allow_kb_focus + and self.active_dialog + ): self.keyboard_focus_element = self.active_dialog # prevent any other tabbing away from active dialog if self.active_dialog: @@ -746,14 +828,14 @@ class UI: # handle shift-tab if reverse: focus_elements.reverse() - for i,element in enumerate(focus_elements[:-1]): + for i, element in enumerate(focus_elements[:-1]): if self.keyboard_focus_element is element: - self.keyboard_focus_element = focus_elements[i+1] + self.keyboard_focus_element = focus_elements[i + 1] break # update keyboard hover for both self.edit_object_panel.update_keyboard_hover() self.edit_list_panel.update_keyboard_hover() - + def refocus_keyboard(self): "called when an element closes, sets new keyboard_focus_element" if self.active_dialog: @@ -764,27 +846,31 @@ class UI: self.keyboard_focus_element = self.popup elif self.pulldown.visible: self.keyboard_focus_element = self.pulldown - elif self.edit_list_panel.is_visible() and not self.edit_object_panel.is_visible(): + elif ( + self.edit_list_panel.is_visible() + and not self.edit_object_panel.is_visible() + ): self.keyboard_focus_element = self.edit_list_panel - elif self.edit_object_panel.is_visible() and not self.edit_list_panel.is_visible(): + elif ( + self.edit_object_panel.is_visible() + and not self.edit_list_panel.is_visible() + ): self.keyboard_focus_element = self.edit_object_panel - + def keyboard_navigate(self, move_x, move_y): self.keyboard_focus_element.keyboard_navigate(move_x, move_y) - + def toggle_game_edit_ui(self): # if editing is disallowed, only run this once to disable UI - if not self.app.can_edit: - return - elif not self.app.game_mode: + if not self.app.can_edit or not self.app.game_mode: return self.set_game_edit_ui_visibility(not self.game_menu_bar.visible) - + def destroy(self): for e in self.elements: e.destroy() self.grain_texture.destroy() - + def render(self): for e in self.elements: if e.is_visible(): diff --git a/ui_art_dialog.py b/ui_art_dialog.py index 7e34f0a..26d2e45 100644 --- a/ui_art_dialog.py +++ b/ui_art_dialog.py @@ -1,32 +1,37 @@ import os.path -from ui_dialog import UIDialog, Field -from ui_chooser_dialog import ChooserDialog, ChooserItemButton, ChooserItem - -from ui_console import OpenCommand, SaveCommand -from art import ART_DIR, ART_FILE_EXTENSION, DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_FRAME_DELAY, DEFAULT_LAYER_Z_OFFSET +from art import ( + ART_DIR, + ART_FILE_EXTENSION, + DEFAULT_FRAME_DELAY, + DEFAULT_HEIGHT, + DEFAULT_LAYER_Z_OFFSET, + DEFAULT_WIDTH, +) from palette import PaletteFromFile +from ui_chooser_dialog import ChooserDialog, ChooserItem, ChooserItemButton +from ui_console import SaveCommand +from ui_dialog import Field, UIDialog class BaseFileDialog(UIDialog): - - invalid_filename_error = 'Filename is not valid.' - filename_exists_error = 'File by that name already exists.' - + invalid_filename_error = "Filename is not valid." + filename_exists_error = "File by that name already exists." + def get_file_extension(self): - return '' - + return "" + def get_dir(self): - return '' - + return "" + def get_full_filename(self, filename, dir=None): for forbidden_char in self.ui.app.forbidden_filename_chars: if forbidden_char in filename: return - full_filename = self.get_dir() + '/' + filename - full_filename += '.' + self.get_file_extension() + full_filename = self.get_dir() + "/" + filename + full_filename += "." + self.get_file_extension() return full_filename - + def is_filename_valid(self, field_number): filename = self.field_texts[field_number].strip() # filename can't be only whitespace @@ -40,55 +45,55 @@ class BaseFileDialog(UIDialog): return True, self.filename_exists_error return True, None + class NewArtDialog(BaseFileDialog): - - title = 'New art' - field0_label = 'Filename of new art:' - field2_label = 'Width:' - field4_label = 'Height:' - field6_label = 'Save folder:' - field7_label = ' %s' + title = "New art" + field0_label = "Filename of new art:" + field2_label = "Width:" + field4_label = "Height:" + field6_label = "Save folder:" + field7_label = " %s" tile_width = 60 field0_width = 56 y_spacing = 0 field1_width = field2_width = UIDialog.default_short_field_width fields = [ Field(label=field0_label, type=str, width=field0_width, oneline=False), - Field(label='', type=None, width=0, oneline=True), + Field(label="", type=None, width=0, oneline=True), Field(label=field2_label, type=int, width=field1_width, oneline=True), - Field(label='', type=None, width=0, oneline=True), + Field(label="", type=None, width=0, oneline=True), Field(label=field4_label, type=int, width=field2_width, oneline=True), - Field(label='', type=None, width=0, oneline=True), + Field(label="", type=None, width=0, oneline=True), Field(label=field6_label, type=None, width=0, oneline=True), Field(label=field7_label, type=None, width=0, oneline=True), - Field(label='', type=None, width=0, oneline=True) + Field(label="", type=None, width=0, oneline=True), ] - confirm_caption = 'Create' - invalid_width_error = 'Invalid width.' - invalid_height_error = 'Invalid height.' - + confirm_caption = "Create" + invalid_width_error = "Invalid width." + invalid_height_error = "Invalid height." + def get_initial_field_text(self, field_number): if field_number == 0: - return 'new%s' % len(self.ui.app.art_loaded_for_edit) + return "new%s" % len(self.ui.app.art_loaded_for_edit) elif field_number == 2: return str(DEFAULT_WIDTH) elif field_number == 4: return str(DEFAULT_HEIGHT) - return '' - + return "" + def get_field_label(self, field_index): label = self.fields[field_index].label # show dir art will be saved into if field_index == 7: label %= self.get_dir() return label - + def get_file_extension(self): return ART_FILE_EXTENSION - + def get_dir(self): return self.ui.app.documents_dir + ART_DIR - + def is_input_valid(self): "warn if file already exists, dimensions must be >0 and <= max" if not self.is_valid_dimension(self.field_texts[2], self.ui.app.max_art_width): @@ -96,91 +101,97 @@ class NewArtDialog(BaseFileDialog): if not self.is_valid_dimension(self.field_texts[4], self.ui.app.max_art_height): return False, self.invalid_height_error return self.is_filename_valid(0) - + def is_valid_dimension(self, dimension, max_dimension): - try: dimension = int(dimension) - except: return False + try: + dimension = int(dimension) + except: + return False return 0 < dimension <= max_dimension - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return name = self.field_texts[0] w, h = int(self.field_texts[2]), int(self.field_texts[4]) self.ui.app.new_art_for_edit(name, w, h) - self.ui.app.log('Created %s.psci with size %s x %s' % (name, w, h)) + self.ui.app.log("Created %s.psci with size %s x %s" % (name, w, h)) self.dismiss() class SaveAsDialog(BaseFileDialog): - - title = 'Save art' - field0_label = 'New filename for art:' - field2_label = 'Save folder:' - field3_label = ' %s' + title = "Save art" + field0_label = "New filename for art:" + field2_label = "Save folder:" + field3_label = " %s" tile_width = 60 field0_width = 56 y_spacing = 0 fields = [ Field(label=field0_label, type=str, width=field0_width, oneline=False), - Field(label='', type=None, width=0, oneline=True), + Field(label="", type=None, width=0, oneline=True), Field(label=field2_label, type=None, width=0, oneline=True), Field(label=field3_label, type=None, width=0, oneline=True), - Field(label='', type=None, width=0, oneline=True) + Field(label="", type=None, width=0, oneline=True), ] - confirm_caption = 'Save' + confirm_caption = "Save" always_redraw_labels = True - + def get_initial_field_text(self, field_number): if field_number == 0: # special case: if opening playscii/art/new, change # it to documents dir to avoid writing to application dir # (still possible if you open other files) if os.path.dirname(self.ui.active_art.filename) == ART_DIR[:-1]: - self.ui.active_art.filename = self.ui.app.documents_dir + self.ui.active_art.filename + self.ui.active_art.filename = ( + self.ui.app.documents_dir + self.ui.active_art.filename + ) # TODO: handle other files from app dir as well? not as important filename = os.path.basename(self.ui.active_art.filename) filename = os.path.splitext(filename)[0] return filename - return '' - + return "" + def get_file_extension(self): """ Return file extension this dialog saves as; other dialogs are based on this class so we don't want to hardcore .psci """ return ART_FILE_EXTENSION - + def get_dir(self): return os.path.dirname(self.ui.active_art.filename) - + def get_field_label(self, field_index): label = self.fields[field_index].label # show dir art will be saved into if field_index == 3: label %= self.get_dir() return label - + def is_input_valid(self): return self.is_filename_valid(0) - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return SaveCommand.execute(self.ui.console, [self.field_texts[0]]) self.dismiss() + class ConvertItemButton(ChooserItemButton): width = 15 big_width = 20 + class ConvertChooserItem(ChooserItem): - def picked(self, element): - + # TODO: following is c+p'd from BaseFileChooserItem.picked, # move this functionality into ChooserItem and override in BFCI? - + # if this is different from the last clicked item, pick it if element.selected_item_index != self.index: ChooserItem.picked(self, element) @@ -194,22 +205,24 @@ class ConvertChooserItem(ChooserItem): return element.confirm_pressed() element.first_selection_made = False - + def get_description_lines(self): - return self.description.split('\n') + return self.description.split("\n") + class ConvertFileDialog(ChooserDialog): "Common functionality for importer and exporter selection dialogs" + tile_width, big_width = 85, 90 tile_height, big_height = 15, 25 - confirm_caption = 'Choose' + confirm_caption = "Choose" show_preview_image = False item_button_class = ConvertItemButton chooser_item_class = ConvertChooserItem - + def get_converters(self): return [] - + def get_items(self): items = [] converters = self.get_converters() @@ -227,11 +240,11 @@ class ConvertFileDialog(ChooserDialog): class ImportFileDialog(ConvertFileDialog): - title = 'Choose an importer' - + title = "Choose an importer" + def get_converters(self): return self.ui.app.get_importers() - + def confirm_pressed(self): # open file select dialog so user can choose what to import item = self.get_selected_item() @@ -241,34 +254,41 @@ class ImportFileDialog(ConvertFileDialog): self.dismiss() self.ui.open_dialog(self.ui.app.importer.file_chooser_dialog_class) + class ImportOptionsDialog(UIDialog): "Generic base class for importer options" - confirm_caption = 'Import' + + confirm_caption = "Import" + def do_import(app, filename, options): "Common 'run importer' code for end of import options dialog" # if importer needs no options, run it importer = app.importer(app, filename, options) if importer.success: if app.importer.completes_instantly: - app.log('Imported %s successfully.' % filename) + app.log("Imported %s successfully." % filename) app.importer = None + class ExportOptionsDialog(UIDialog): "Generic base class for exporter options" - confirm_caption = 'Export' + + confirm_caption = "Export" + def do_export(app, filename, options): "Common 'run exporter' code for end of import options dialog" # if importer needs no options, run it exporter = app.exporter(app, filename, options) if exporter.success: - app.log('Exported %s successfully.' % exporter.out_filename) + app.log("Exported %s successfully." % exporter.out_filename) + class ExportFileDialog(ConvertFileDialog): - title = 'Choose an exporter' - + title = "Choose an exporter" + def get_converters(self): return self.ui.app.get_exporters() - + def confirm_pressed(self): # open file select dialog so user can choose what to import item = self.get_selected_item() @@ -280,10 +300,10 @@ class ExportFileDialog(ConvertFileDialog): class ExportFilenameInputDialog(SaveAsDialog): - title = 'Export art' - field0_label = 'New filename for exported art:' - confirm_caption = 'Export' - + title = "Export art" + field0_label = "New filename for exported art:" + confirm_caption = "Export" + def get_initial_field_text(self, field_number): # base output filename on art filename if field_number == 0: @@ -291,46 +311,45 @@ class ExportFilenameInputDialog(SaveAsDialog): out_filename = os.path.basename(out_filename) out_filename = os.path.splitext(out_filename)[0] return out_filename - + def get_file_extension(self): return self.ui.app.exporter.file_extension - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return filename = self.field_texts[0] self.dismiss() # invoke options dialog if exporter has one, else invoke exporter if self.ui.app.exporter.options_dialog_class: # pass filename into new dialog - options = {'filename': filename} - self.ui.open_dialog(self.ui.app.exporter.options_dialog_class, - options) + options = {"filename": filename} + self.ui.open_dialog(self.ui.app.exporter.options_dialog_class, options) else: ExportOptionsDialog.do_export(self.ui.app, filename, {}) class QuitUnsavedChangesDialog(UIDialog): - - title = 'Unsaved changes' - message = 'Save changes to %s?' - confirm_caption = 'Save' + title = "Unsaved changes" + message = "Save changes to %s?" + confirm_caption = "Save" other_button_visible = True other_caption = "Don't Save" - + def confirm_pressed(self): SaveCommand.execute(self.ui.console, []) self.dismiss() # try again, see if another art has unsaved changes self.ui.app.il.BIND_quit() - + def other_pressed(self): # kind of a hack: make the check BIND_quit does come up false # for this art. externalities fairly minor. self.ui.active_art.unsaved_changes = False self.dismiss() self.ui.app.il.BIND_quit() - + def get_message(self): # get base name (ie no dirs) filename = os.path.basename(self.ui.active_art.filename) @@ -338,12 +357,11 @@ class QuitUnsavedChangesDialog(UIDialog): class CloseUnsavedChangesDialog(QuitUnsavedChangesDialog): - def confirm_pressed(self): SaveCommand.execute(self.ui.console, []) self.dismiss() self.ui.app.il.BIND_close_art() - + def other_pressed(self): self.ui.active_art.unsaved_changes = False self.dismiss() @@ -351,41 +369,39 @@ class CloseUnsavedChangesDialog(QuitUnsavedChangesDialog): class RevertChangesDialog(UIDialog): - - title = 'Revert changes' - message = 'Revert changes to %s?' - confirm_caption = 'Revert' - + title = "Revert changes" + message = "Revert changes to %s?" + confirm_caption = "Revert" + def confirm_pressed(self): self.ui.app.revert_active_art() self.dismiss() - + def get_message(self): filename = os.path.basename(self.ui.active_art.filename) return [self.message % filename] class ResizeArtDialog(UIDialog): - - title = 'Resize art' + title = "Resize art" field_width = UIDialog.default_short_field_width - field0_label = 'New Width:' - field1_label = 'New Height:' - field2_label = 'Crop Start X:' - field3_label = 'Crop Start Y:' - field4_label = 'Fill new tiles with BG color' + field0_label = "New Width:" + field1_label = "New Height:" + field2_label = "Crop Start X:" + field3_label = "Crop Start Y:" + field4_label = "Fill new tiles with BG color" fields = [ Field(label=field0_label, type=int, width=field_width, oneline=True), Field(label=field1_label, type=int, width=field_width, oneline=True), Field(label=field2_label, type=int, width=field_width, oneline=True), Field(label=field3_label, type=int, width=field_width, oneline=True), - Field(label=field4_label, type=bool, width=0, oneline=True) + Field(label=field4_label, type=bool, width=0, oneline=True), ] - confirm_caption = 'Resize' - invalid_width_error = 'Invalid width.' - invalid_height_error = 'Invalid height.' - invalid_start_error = 'Invalid crop origin.' - + confirm_caption = "Resize" + invalid_width_error = "Invalid width." + invalid_height_error = "Invalid height." + invalid_start_error = "Invalid crop origin." + def get_initial_field_text(self, field_number): if field_number == 0: return str(self.ui.active_art.width) @@ -394,32 +410,39 @@ class ResizeArtDialog(UIDialog): elif field_number == 4: return UIDialog.true_field_text else: - return '0' - + return "0" + def is_input_valid(self): "file can't already exist, dimensions must be >0 and <= max" if not self.is_valid_dimension(self.field_texts[0], self.ui.app.max_art_width): return False, self.invalid_width_error if not self.is_valid_dimension(self.field_texts[1], self.ui.app.max_art_height): return False, self.invalid_height_error - try: int(self.field_texts[2]) - except: return False, self.invalid_start_error + try: + int(self.field_texts[2]) + except: + return False, self.invalid_start_error if not 0 <= int(self.field_texts[2]) < self.ui.active_art.width: return False, self.invalid_start_error - try: int(self.field_texts[3]) - except: return False, self.invalid_start_error + try: + int(self.field_texts[3]) + except: + return False, self.invalid_start_error if not 0 <= int(self.field_texts[3]) < self.ui.active_art.height: return False, self.invalid_start_error return True, None - + def is_valid_dimension(self, dimension, max_dimension): - try: dimension = int(dimension) - except: return False + try: + dimension = int(dimension) + except: + return False return 0 < dimension <= max_dimension - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return w, h = int(self.field_texts[0]), int(self.field_texts[1]) start_x, start_y = int(self.field_texts[2]), int(self.field_texts[3]) bg_fill = bool(self.field_texts[4].strip()) @@ -431,123 +454,134 @@ class ResizeArtDialog(UIDialog): # layer menu dialogs # + class AddFrameDialog(UIDialog): - - title = 'Add new frame' - field0_label = 'Index to add frame before:' - field1_label = 'Hold time (in seconds) for new frame:' + title = "Add new frame" + field0_label = "Index to add frame before:" + field1_label = "Hold time (in seconds) for new frame:" field_width = UIDialog.default_short_field_width fields = [ Field(label=field0_label, type=int, width=field_width, oneline=True), - Field(label=field1_label, type=float, width=field_width, oneline=False) + Field(label=field1_label, type=float, width=field_width, oneline=False), ] - confirm_caption = 'Add' - invalid_index_error = 'Invalid index. (1-%s allowed)' - invalid_delay_error = 'Invalid hold time.' - + confirm_caption = "Add" + invalid_index_error = "Invalid index. (1-%s allowed)" + invalid_delay_error = "Invalid hold time." + def get_initial_field_text(self, field_number): if field_number == 0: return str(self.ui.active_art.frames + 1) elif field_number == 1: return str(DEFAULT_FRAME_DELAY) - + def is_valid_frame_index(self, index): - try: index = int(index) - except: return False + try: + index = int(index) + except: + return False if index < 1 or index > self.ui.active_art.frames + 1: return False return True - + def is_valid_frame_delay(self, delay): - try: delay = float(delay) - except: return False + try: + delay = float(delay) + except: + return False return delay > 0 - + def is_input_valid(self): if not self.is_valid_frame_index(self.field_texts[0]): return False, self.invalid_index_error % str(self.ui.active_art.frames + 1) if not self.is_valid_frame_delay(self.field_texts[1]): return False, self.invalid_delay_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return index = int(self.field_texts[0]) delay = float(self.field_texts[1]) self.ui.active_art.insert_frame_before_index(index - 1, delay) self.dismiss() + class DuplicateFrameDialog(AddFrameDialog): - title = 'Duplicate frame' - confirm_caption = 'Duplicate' + title = "Duplicate frame" + confirm_caption = "Duplicate" + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return index = int(self.field_texts[0]) delay = float(self.field_texts[1]) - self.ui.active_art.duplicate_frame(self.ui.active_art.active_frame, index - 1, delay) + self.ui.active_art.duplicate_frame( + self.ui.active_art.active_frame, index - 1, delay + ) self.dismiss() + class FrameDelayDialog(AddFrameDialog): - - field0_label = 'New hold time (in seconds) for frame:' + field0_label = "New hold time (in seconds) for frame:" field_width = UIDialog.default_short_field_width - fields = [ - Field(label=field0_label, type=float, width=field_width, oneline=False) - ] - confirm_caption = 'Set' - + fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)] + confirm_caption = "Set" + def get_initial_field_text(self, field_number): if field_number == 0: return str(self.ui.active_art.frame_delays[self.ui.active_art.active_frame]) - + def is_input_valid(self): if not self.is_valid_frame_delay(self.field_texts[0]): return False, self.invalid_delay_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return delay = float(self.field_texts[0]) self.ui.active_art.frame_delays[self.ui.active_art.active_frame] = delay self.dismiss() + class FrameDelayAllDialog(FrameDelayDialog): - field0_label = 'New hold time (in seconds) for all frames:' + field0_label = "New hold time (in seconds) for all frames:" field_width = UIDialog.default_short_field_width - fields = [ - Field(label=field0_label, type=float, width=field_width, oneline=False) - ] - + fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)] + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return delay = float(self.field_texts[0]) for i in range(self.ui.active_art.frames): self.ui.active_art.frame_delays[i] = delay self.dismiss() + class FrameIndexDialog(AddFrameDialog): - field0_label = 'Move this frame before index:' + field0_label = "Move this frame before index:" field_width = UIDialog.default_short_field_width - fields = [ - Field(label=field0_label, type=int, width=field_width, oneline=False) - ] - confirm_caption = 'Set' - + fields = [Field(label=field0_label, type=int, width=field_width, oneline=False)] + confirm_caption = "Set" + def is_input_valid(self): if not self.is_valid_frame_index(self.field_texts[0]): return False, self.invalid_index_error % str(self.ui.active_art.frames + 1) return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return # set new frame index (effectively moving it in the sequence) dest_index = int(self.field_texts[0]) - self.ui.active_art.move_frame_to_index(self.ui.active_art.active_frame, dest_index) + self.ui.active_art.move_frame_to_index( + self.ui.active_art.active_frame, dest_index + ) self.dismiss() @@ -555,46 +589,52 @@ class FrameIndexDialog(AddFrameDialog): # layer menu dialogs # + class AddLayerDialog(UIDialog): - - title = 'Add new layer' - field0_label = 'Name for new layer:' - field1_label = 'Z-depth for new layer:' + title = "Add new layer" + field0_label = "Name for new layer:" + field1_label = "Z-depth for new layer:" field0_width = UIDialog.default_field_width field1_width = UIDialog.default_short_field_width fields = [ Field(label=field0_label, type=str, width=field0_width, oneline=False), - Field(label=field1_label, type=float, width=field1_width, oneline=True) + Field(label=field1_label, type=float, width=field1_width, oneline=True), ] - confirm_caption = 'Add' - name_exists_error = 'Layer by that name already exists.' - invalid_z_error = 'Invalid number.' - + confirm_caption = "Add" + name_exists_error = "Layer by that name already exists." + invalid_z_error = "Invalid number." + def get_initial_field_text(self, field_number): if field_number == 0: - return 'Layer %s' % str(self.ui.active_art.layers + 1) + return "Layer %s" % str(self.ui.active_art.layers + 1) elif field_number == 1: - return str(self.ui.active_art.layers_z[self.ui.active_art.active_layer] + DEFAULT_LAYER_Z_OFFSET) - + return str( + self.ui.active_art.layers_z[self.ui.active_art.active_layer] + + DEFAULT_LAYER_Z_OFFSET + ) + def is_valid_layer_name(self, name, exclude_active_layer=False): - for i,layer_name in enumerate(self.ui.active_art.layer_names): + for i, layer_name in enumerate(self.ui.active_art.layer_names): if exclude_active_layer and i == self.ui.active_layer: continue if layer_name == name: return False return True - + def is_input_valid(self): valid_name = self.is_valid_layer_name(self.field_texts[0]) if not valid_name: return False, self.name_exists_error - try: z = float(self.field_texts[1]) - except: return False, self.invalid_z_error + try: + z = float(self.field_texts[1]) + except: + return False, self.invalid_z_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return name = self.field_texts[0] z = float(self.field_texts[1]) self.ui.active_art.add_layer(z, name) @@ -602,12 +642,13 @@ class AddLayerDialog(UIDialog): class DuplicateLayerDialog(AddLayerDialog): - title = 'Duplicate layer' - confirm_caption = 'Duplicate' - + title = "Duplicate layer" + confirm_caption = "Duplicate" + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return name = self.field_texts[0] z = float(self.field_texts[1]) self.ui.active_art.duplicate_layer(self.ui.active_art.active_layer, z, name) @@ -615,15 +656,12 @@ class DuplicateLayerDialog(AddLayerDialog): class SetLayerNameDialog(AddLayerDialog): - - title = 'Set layer name' - field0_label = 'New name for this layer:' + title = "Set layer name" + field0_label = "New name for this layer:" field_width = UIDialog.default_field_width - fields = [ - Field(label=field0_label, type=str, width=field_width, oneline=False) - ] - confirm_caption = 'Rename' - + fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)] + confirm_caption = "Rename" + def confirm_pressed(self): new_name = self.field_texts[0] self.ui.active_art.layer_names[self.ui.active_art.active_layer] = new_name @@ -632,28 +670,29 @@ class SetLayerNameDialog(AddLayerDialog): class SetLayerZDialog(UIDialog): - title = 'Set layer Z-depth' - field0_label = 'Z-depth for layer:' + title = "Set layer Z-depth" + field0_label = "Z-depth for layer:" field_width = UIDialog.default_short_field_width - fields = [ - Field(label=field0_label, type=float, width=field_width, oneline=False) - ] - confirm_caption = 'Set' - invalid_z_error = 'Invalid number.' - + fields = [Field(label=field0_label, type=float, width=field_width, oneline=False)] + confirm_caption = "Set" + invalid_z_error = "Invalid number." + def get_initial_field_text(self, field_number): # populate with existing z if field_number == 0: return str(self.ui.active_art.layers_z[self.ui.active_art.active_layer]) - + def is_input_valid(self): - try: z = float(self.field_texts[0]) - except: return False, self.invalid_z_error + try: + z = float(self.field_texts[0]) + except: + return False, self.invalid_z_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return new_z = float(self.field_texts[0]) self.ui.active_art.layers_z[self.ui.active_art.active_layer] = new_z self.ui.active_art.set_unsaved_changes(True) @@ -662,33 +701,35 @@ class SetLayerZDialog(UIDialog): class PaletteFromFileDialog(UIDialog): - title = 'Create palette from file' - field0_label = 'Filename to create palette from:' - field1_label = 'Filename for new palette:' - field2_label = 'Colors in new palette:' + title = "Create palette from file" + field0_label = "Filename to create palette from:" + field1_label = "Filename for new palette:" + field2_label = "Colors in new palette:" field0_width = field1_width = UIDialog.default_field_width field2_width = UIDialog.default_short_field_width fields = [ Field(label=field0_label, type=str, width=field0_width, oneline=False), Field(label=field1_label, type=str, width=field1_width, oneline=False), - Field(label=field2_label, type=int, width=field2_width, oneline=True) + Field(label=field2_label, type=int, width=field2_width, oneline=True), ] - confirm_caption = 'Create' - invalid_color_error = 'Palettes must be between 2 and 256 colors.' - bad_output_filename_error = 'Enter a filename for the new palette.' - + confirm_caption = "Create" + invalid_color_error = "Palettes must be between 2 and 256 colors." + bad_output_filename_error = "Enter a filename for the new palette." + def get_initial_field_text(self, field_number): # NOTE: PaletteFromImageChooserDialog.confirm_pressed which invokes us # sets fields 0 and 1 if field_number == 2: return str(256) - return '' - + return "" + def valid_colors(self, colors): - try: c = int(colors) - except: return False + try: + c = int(colors) + except: + return False return 2 <= c <= 256 - + def is_input_valid(self): valid_colors = self.valid_colors(self.field_texts[2]) if not valid_colors: @@ -696,10 +737,11 @@ class PaletteFromFileDialog(UIDialog): if not self.field_texts[1].strip(): return False, self.bad_output_filename_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return src_filename = self.field_texts[0] palette_filename = self.field_texts[1] colors = int(self.field_texts[2]) @@ -708,32 +750,33 @@ class PaletteFromFileDialog(UIDialog): class SetCameraZoomDialog(UIDialog): - title = 'Set camera zoom' - field0_label = 'New camera zoom %:' + title = "Set camera zoom" + field0_label = "New camera zoom %:" field_width = UIDialog.default_short_field_width - fields = [ - Field(label=field0_label, type=float, width=field_width, oneline=True) - ] - confirm_caption = 'Set' - invalid_zoom_error = 'Zoom % must be a number greater than zero.' + fields = [Field(label=field0_label, type=float, width=field_width, oneline=True)] + confirm_caption = "Set" + invalid_zoom_error = "Zoom % must be a number greater than zero." all_modes_visible = True game_mode_visible = True - + def get_initial_field_text(self, field_number): if field_number == 0: - return '%.1f' % self.ui.app.camera.get_current_zoom_pct() - return '' - + return "%.1f" % self.ui.app.camera.get_current_zoom_pct() + return "" + def is_input_valid(self): - try: zoom = float(self.field_texts[0]) - except: return False, self.invalid_zoom_error + try: + zoom = float(self.field_texts[0]) + except: + return False, self.invalid_zoom_error if zoom <= 0: return False, self.invalid_zoom_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return new_zoom_pct = float(self.field_texts[0]) camera = self.ui.app.camera camera.z = camera.get_base_zoom() / (new_zoom_pct / 100) @@ -741,30 +784,31 @@ class SetCameraZoomDialog(UIDialog): class OverlayImageOpacityDialog(UIDialog): - title = 'Set overlay image opacity' - field0_label = 'New overlay opacity %:' + title = "Set overlay image opacity" + field0_label = "New overlay opacity %:" field_width = UIDialog.default_short_field_width - fields = [ - Field(label=field0_label, type=float, width=field_width, oneline=True) - ] - confirm_caption = 'Set' - invalid_opacity_error = 'Opacity % must be between 0 and 100.' - + fields = [Field(label=field0_label, type=float, width=field_width, oneline=True)] + confirm_caption = "Set" + invalid_opacity_error = "Opacity % must be between 0 and 100." + def get_initial_field_text(self, field_number): if field_number == 0: - return '%.1f' % (self.ui.app.overlay_renderable.alpha * 100) - return '' - + return "%.1f" % (self.ui.app.overlay_renderable.alpha * 100) + return "" + def is_input_valid(self): - try: opacity = float(self.field_texts[0]) - except: return False, self.invalid_opacity_error + try: + opacity = float(self.field_texts[0]) + except: + return False, self.invalid_opacity_error if opacity <= 0 or opacity > 100: return False, self.invalid_opacity_error return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return new_opacity = float(self.field_texts[0]) self.ui.app.overlay_renderable.alpha = new_opacity / 100 self.dismiss() diff --git a/ui_button.py b/ui_button.py index e7d69c4..28f1ca3 100644 --- a/ui_button.py +++ b/ui_button.py @@ -1,20 +1,19 @@ - from ui_colors import UIColors TEXT_LEFT = 0 TEXT_CENTER = 1 TEXT_RIGHT = 2 -BUTTON_STATES = ['normal', 'hovered', 'clicked', 'dimmed'] +BUTTON_STATES = ["normal", "hovered", "clicked", "dimmed"] + class UIButton: - "clickable button that does something in a UIElement" - + # x/y/width/height given in tile scale x, y = 0, 0 width, height = 1, 1 - caption = 'TEST' + caption = "TEST" caption_justify = TEXT_LEFT # paint caption from string, or not should_draw_caption = True @@ -44,65 +43,72 @@ class UIButton: # if true, display a tooltip when hovered, and dismiss it when unhovered. # contents set from get_tooltip_text and positioned by get_tooltip_location. tooltip_on_hover = False - + def __init__(self, element, starting_state=None): self.element = element - self.state = starting_state or 'normal' - + self.state = starting_state or "normal" + def log_event(self, event_type): "common code for button event logging" if self.element.ui.logg: - self.element.ui.app.log("UIButton: %s's %s %s" % (self.element.__class__.__name__, self.__class__.__name__, event_type)) - + self.element.ui.app.log( + "UIButton: %s's %s %s" + % (self.element.__class__.__name__, self.__class__.__name__, event_type) + ) + def set_state(self, new_state): - if not new_state in BUTTON_STATES: - self.element.ui.app.log('Unrecognized state for button %s: %s' % (self.__class__.__name__, new_state)) + if new_state not in BUTTON_STATES: + self.element.ui.app.log( + "Unrecognized state for button %s: %s" + % (self.__class__.__name__, new_state) + ) return - self.dimmed = new_state == 'dimmed' + self.dimmed = new_state == "dimmed" self.state = new_state self.set_state_colors() - + def get_state_colors(self, state): - fg = getattr(self, '%s_fg_color' % state) - bg = getattr(self, '%s_bg_color' % state) + fg = getattr(self, "%s_fg_color" % state) + bg = getattr(self, "%s_bg_color" % state) return fg, bg - + def set_state_colors(self): if self.never_draw: return # set colors for entire button area based on current state - if self.dimmed and self.state == 'normal': - self.state = 'dimmed' + if self.dimmed and self.state == "normal": + self.state = "dimmed" # just bail if we're trying to draw something out of bounds - if self.x + self.width > self.element.art.width: - return - elif self.y + self.height > self.element.art.height: + if ( + self.x + self.width > self.element.art.width + or self.y + self.height > self.element.art.height + ): return fg, bg = self.get_state_colors(self.state) for y in range(self.height): for x in range(self.width): self.element.art.set_tile_at(0, 0, self.x + x, self.y + y, None, fg, bg) - + def update_tooltip(self): tt = self.element.ui.tooltip tt.reset_art() tt.set_text(self.get_tooltip_text()) tt.tile_x, tt.tile_y = self.get_tooltip_location() tt.reset_loc() - + def hover(self): - self.log_event('hovered') - self.set_state('hovered') + self.log_event("hovered") + self.set_state("hovered") if self.tooltip_on_hover: self.element.ui.tooltip.visible = True self.update_tooltip() - + def unhover(self): - self.log_event('unhovered') + self.log_event("unhovered") if self.dimmed: - self.set_state('dimmed') + self.set_state("dimmed") else: - self.set_state('normal') + self.set_state("normal") if self.tooltip_on_hover: # if two buttons are adjacent, we might be unhovering this one # right after hovering the other in the same frame. if so, @@ -115,31 +121,31 @@ class UIButton: another_tooltip = True if not another_tooltip: self.element.ui.tooltip.visible = False - + def click(self): - self.log_event('clicked') - self.set_state('clicked') - + self.log_event("clicked") + self.set_state("clicked") + def unclick(self): - self.log_event('unclicked') + self.log_event("unclicked") if self in self.element.hovered_buttons: self.hover() else: self.unhover() - + def get_tooltip_text(self): "override in a subclass to define this button's tooltip text" - return 'ERROR' - + return "ERROR" + def get_tooltip_location(self): "override in a subclass to define this button's tooltip screen location" return 10, 10 - + def draw_caption(self): y = self.y + self.caption_y text = self.caption # trim if too long - text = text[:self.width] + text = text[: self.width] if self.caption_justify == TEXT_CENTER: text = text.center(self.width) elif self.caption_justify == TEXT_RIGHT: @@ -150,10 +156,10 @@ class UIButton: if self.clear_before_caption_draw: for ty in range(self.height): for tx in range(self.width): - self.element.art.set_char_index_at(0, 0, self.x+tx, y+ty, 0) + self.element.art.set_char_index_at(0, 0, self.x + tx, y + ty, 0) # leave FG color None; should already have been set self.element.art.write_string(0, 0, self.x, y, text, None) - + def draw(self): if self.never_draw: return diff --git a/ui_chooser_dialog.py b/ui_chooser_dialog.py index db29ad7..e4bf2be 100644 --- a/ui_chooser_dialog.py +++ b/ui_chooser_dialog.py @@ -1,31 +1,29 @@ -# coding=utf-8 - import os + import sdl2 +from art import UV_FLIPY, UV_NORMAL from renderable_sprite import UISpriteRenderable -from ui_dialog import UIDialog, Field from ui_button import UIButton -from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY from ui_colors import UIColors +from ui_dialog import Field, UIDialog class ChooserItemButton(UIButton): - "button representing a ChooserItem" - + item = None width = 20 big_width = 30 clear_before_caption_draw = True - + def __init__(self, element): # more room for list items if screen is wide enough if element.ui.width_tiles - 20 > element.big_width: self.width = self.big_width UIButton.__init__(self, element) self.callback = self.pick_item - + def pick_item(self): if not self.item: return @@ -33,20 +31,20 @@ class ChooserItemButton(UIButton): class ScrollArrowButton(UIButton): - "button that scrolls up or down in a chooser item view" - + arrow_char = 129 up = True normal_bg_color = UIDialog.bg_color dimmed_fg_color = UIColors.medgrey dimmed_bg_color = UIDialog.bg_color - + def draw_caption(self): xform = [UV_FLIPY, UV_NORMAL][self.up] - self.element.art.set_tile_at(0, 0, self.x, self.y + self.caption_y, - self.arrow_char, None, None, xform) - + self.element.art.set_tile_at( + 0, 0, self.x, self.y + self.caption_y, self.arrow_char, None, None, xform + ) + def callback(self): if self.up and self.element.scroll_index > 0: self.element.scroll_index -= 1 @@ -59,9 +57,8 @@ class ScrollArrowButton(UIButton): class ChooserItem: - - label = 'Chooser item' - + label = "Chooser item" + def __init__(self, index, name): self.index = index # item's unique name, eg a filename @@ -69,43 +66,44 @@ class ChooserItem: self.label = self.get_label() # validity flag lets ChooserItem subclasses exclude themselves self.valid = True - - def get_label(self): return self.name - - def get_description_lines(self): return [] - - def get_preview_texture(self): return None - - def load(self, app): pass - + + def get_label(self): + return self.name + + def get_description_lines(self): + return [] + + def get_preview_texture(self): + return None + + def load(self, app): + pass + def picked(self, element): # set item selected and refresh preview element.set_selected_item_index(self.index) class ChooserDialog(UIDialog): - - title = 'Chooser' - confirm_caption = 'Set' - cancel_caption = 'Close' - message = '' + title = "Chooser" + confirm_caption = "Set" + cancel_caption = "Close" + message = "" # if True, chooser shows files; show filename on first line of description show_filenames = False directory_aware = False tile_width, tile_height = 60, 20 # use these if screen is big enough big_width, big_height = 80, 30 - fields = [ - Field(label='', type=str, width=tile_width - 4, oneline=True) - ] + fields = [Field(label="", type=str, width=tile_width - 4, oneline=True)] item_start_x, item_start_y = 2, 4 - no_preview_label = 'No preview available!' + no_preview_label = "No preview available!" show_preview_image = True item_button_class = ChooserItemButton chooser_item_class = ChooserItem scrollbar_shade_char = 54 flip_preview_y = True - + def __init__(self, ui, options): self.ui = ui # semikludge: track whether user has selected anything in a new dir, @@ -113,12 +111,13 @@ class ChooserDialog(UIDialog): self.first_selection_made = False if self.ui.width_tiles - 20 > self.big_width: self.tile_width = self.big_width - self.fields[0] = Field(label='', type=str, - width=self.tile_width - 4, oneline=True) + self.fields[0] = Field( + label="", type=str, width=self.tile_width - 4, oneline=True + ) if self.ui.height_tiles - 30 > self.big_height: self.tile_height = self.big_height self.items_in_view = self.tile_height - self.item_start_y - 3 - self.field_texts = [''] + self.field_texts = [""] # set active field earlier than UIDialog.init so set_initial_dir # can change its text self.active_field = 0 @@ -149,7 +148,7 @@ class ChooserDialog(UIDialog): self.preview_renderable.blend = False # offset into items list view provided by buttons starts from self.position_preview() - + def init_buttons(self): for i in range(self.items_in_view): button = self.item_button_class(self) @@ -167,24 +166,24 @@ class ChooserDialog(UIDialog): self.down_arrow_button.y = self.item_start_y + self.items_in_view - 1 self.down_arrow_button.up = False self.buttons += [self.up_arrow_button, self.down_arrow_button] - + def set_initial_dir(self): # for directory-aware dialogs, subclasses specify here where to start - self.current_dir = '.' - + self.current_dir = "." + def change_current_dir(self, new_dir): # check permissions: # os.access(new_dir, os.R_OK) seems to always return True, # so try/catch listdir instead try: l = os.listdir(new_dir) - except PermissionError as e: - line = 'No permission to access %s!' % os.path.abspath(new_dir) + except PermissionError: + line = "No permission to access %s!" % os.path.abspath(new_dir) self.ui.message_line.post_line(line, error=True) return False self.current_dir = new_dir - if not self.current_dir.endswith('/'): - self.current_dir += '/' + if not self.current_dir.endswith("/"): + self.current_dir += "/" # redo items and redraw self.selected_item_index = 0 self.scroll_index = 0 @@ -192,9 +191,8 @@ class ChooserDialog(UIDialog): self.items = self.get_items() self.reset_art(False) return True - - def set_selected_item_index(self, new_index, set_field_text=True, - update_view=True): + + def set_selected_item_index(self, new_index, set_field_text=True, update_view=True): """ set the view's selected item to specified index perform usually-necessary refresh functions for convenience @@ -202,17 +200,25 @@ class ChooserDialog(UIDialog): move_dir = new_index - self.selected_item_index self.selected_item_index = new_index can_scroll = len(self.items) > self.items_in_view - should_scroll = self.selected_item_index >= self.scroll_index + self.items_in_view or self.selected_item_index < self.scroll_index + should_scroll = ( + self.selected_item_index >= self.scroll_index + self.items_in_view + or self.selected_item_index < self.scroll_index + ) if not can_scroll: self.scroll_index = 0 elif should_scroll: # keep selection in bounds - self.selected_item_index = min(self.selected_item_index, len(self.items)-1) + self.selected_item_index = min( + self.selected_item_index, len(self.items) - 1 + ) # scrolling up if move_dir <= 0: self.scroll_index = self.selected_item_index # scrolling down - elif move_dir > 0 and self.selected_item_index - self.scroll_index == self.items_in_view: + elif ( + move_dir > 0 + and self.selected_item_index - self.scroll_index == self.items_in_view + ): self.scroll_index = self.selected_item_index - self.items_in_view + 1 # keep scroll in bounds self.scroll_index = min(self.scroll_index, self.get_max_scroll()) @@ -225,31 +231,35 @@ class ChooserDialog(UIDialog): self.load_selected_item() self.reset_art(False) self.position_preview() - + def get_max_scroll(self): return len(self.items) - self.items_in_view - + def get_selected_item(self): # return None if out of bounds - return self.items[self.selected_item_index] if self.selected_item_index < len(self.items) else None - + return ( + self.items[self.selected_item_index] + if self.selected_item_index < len(self.items) + else None + ) + def load_selected_item(self): item = self.get_selected_item() item.load(self.ui.app) - + def get_initial_selection(self): # subclasses return index of initial selection return 0 - + def set_preview(self): item = self.get_selected_item() if self.show_preview_image: self.preview_renderable.texture = item.get_preview_texture(self.ui.app) - + def get_items(self): # subclasses generate lists of items here return [] - + def position_preview(self, reset=True): if reset: self.set_preview() @@ -261,29 +271,38 @@ class ChooserDialog(UIDialog): self.preview_renderable.x = self.x + x self.preview_renderable.scale_x = (self.tile_width - 2) * qw - x # determine height based on width, then y position - img_inv_aspect = self.preview_renderable.texture.height / self.preview_renderable.texture.width + img_inv_aspect = ( + self.preview_renderable.texture.height + / self.preview_renderable.texture.width + ) screen_aspect = self.ui.app.window_width / self.ui.app.window_height - self.preview_renderable.scale_y = self.preview_renderable.scale_x * img_inv_aspect * screen_aspect + self.preview_renderable.scale_y = ( + self.preview_renderable.scale_x * img_inv_aspect * screen_aspect + ) y = (self.description_end_y + 1) * qh # if preview height is above max allotted size, set height to fill size # and scale down width max_y = (self.tile_height - 3) * qh if self.preview_renderable.scale_y > max_y - y: self.preview_renderable.scale_y = max_y - y - self.preview_renderable.scale_x = self.preview_renderable.scale_y * (1 / img_inv_aspect) * (1 / screen_aspect) + self.preview_renderable.scale_x = ( + self.preview_renderable.scale_y + * (1 / img_inv_aspect) + * (1 / screen_aspect) + ) # flip in Y for some (palettes) but not for others (charsets) if self.flip_preview_y: self.preview_renderable.scale_y = -self.preview_renderable.scale_y else: y += self.preview_renderable.scale_y self.preview_renderable.y = self.y - y - + def get_height(self, msg_lines): return self.tile_height - + def reset_buttons(self): # (re)generate buttons from contents of self.items - for i,button in enumerate(self.item_buttons): + for i, button in enumerate(self.item_buttons): # ??? each button's callback loads charset/palette/whatev if i >= len(self.items): button.never_draw = True @@ -310,23 +329,23 @@ class ChooserDialog(UIDialog): if not self.up_arrow_button: return # dim scroll buttons if we don't have enough items to scroll - state, hover = 'normal', True + state, hover = "normal", True if len(self.items) <= self.items_in_view: - state = 'dimmed' + state = "dimmed" hover = False for button in [self.up_arrow_button, self.down_arrow_button]: button.set_state(state) button.can_hover = hover - + def get_description_filename(self, item): "returns a description-appropriate filename for given item" # truncate from start to fit in description area if needed max_width = self.tile_width max_width -= self.item_start_x + self.item_button_width + 5 if len(item.name) > max_width - 1: - return '…' + item.name[-max_width:] + return "…" + item.name[-max_width:] return item.name - + def get_selected_description_lines(self): item = self.get_selected_item() lines = [] @@ -334,7 +353,7 @@ class ChooserDialog(UIDialog): lines += [self.get_description_filename(item)] lines += item.get_description_lines() or [] return lines - + def draw_selected_description(self): x = self.tile_width - 2 y = self.item_start_y @@ -343,11 +362,10 @@ class ChooserDialog(UIDialog): # trim line if it's too long max_width = self.tile_width - self.item_button_width - 7 line = line[:max_width] - self.art.write_string(0, 0, x, y, line, None, None, - right_justify=True) + self.art.write_string(0, 0, x, y, line, None, None, right_justify=True) y += 1 self.description_end_y = y - + def reset_art(self, resize=True): self.reset_buttons() # UIDialog does: clear window, draw titlebar and confirm/cancel buttons @@ -363,33 +381,34 @@ class ChooserDialog(UIDialog): if len(self.items) <= self.items_in_view: fg = self.up_arrow_button.dimmed_fg_color for y in range(self.up_arrow_button.y + 1, self.down_arrow_button.y): - self.art.set_tile_at(0, 0, self.up_arrow_button.x, y, - self.scrollbar_shade_char, fg) - + self.art.set_tile_at( + 0, 0, self.up_arrow_button.x, y, self.scrollbar_shade_char, fg + ) + def update_drag(self, mouse_dx, mouse_dy): UIDialog.update_drag(self, mouse_dx, mouse_dy) # update thumbnail renderable's position too self.position_preview(False) - + def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed): keystr = sdl2.SDL_GetKeyName(key).decode() # up/down keys navigate list new_index = self.selected_item_index navigated = False - if keystr == 'Return': + if keystr == "Return": # if handle_enter returns True, bail before rest of input handling - # make sure any changes to handle_enter are safe for this! if self.handle_enter(shift_pressed, alt_pressed, ctrl_pressed): return - elif keystr == 'Up': + elif keystr == "Up": navigated = True if self.selected_item_index > 0: new_index -= 1 - elif keystr == 'Down': + elif keystr == "Down": navigated = True if self.selected_item_index < len(self.items) - 1: new_index += 1 - elif keystr == 'PageUp': + elif keystr == "PageUp": navigated = True page_size = int(self.items_in_view / 2) new_index -= page_size @@ -397,7 +416,7 @@ class ChooserDialog(UIDialog): # scroll follows selection jumps self.scroll_index -= page_size self.scroll_index = max(0, self.scroll_index) - elif keystr == 'PageDown': + elif keystr == "PageDown": navigated = True page_size = int(self.items_in_view / 2) new_index += page_size @@ -405,11 +424,11 @@ class ChooserDialog(UIDialog): self.scroll_index += page_size self.scroll_index = min(self.scroll_index, self.get_max_scroll()) # home/end: beginning/end of list, respectively - elif keystr == 'Home': + elif keystr == "Home": navigated = True new_index = 0 self.scroll_index = 0 - elif keystr == 'End': + elif keystr == "End": navigated = True new_index = len(self.items) - 1 self.scroll_index = len(self.items) - self.items_in_view @@ -419,33 +438,33 @@ class ChooserDialog(UIDialog): # if we didn't navigate, seek based on new alphanumeric input if not navigated: self.text_input_seek() - + def text_input_seek(self): field_text = self.field_texts[self.active_field] - if field_text.strip() == '': + if field_text.strip() == "": return # seek should be case-insensitive field_text = field_text.lower() # field text may be a full path; only care about the base field_text = os.path.basename(field_text) - for i,item in enumerate(self.items): + for i, item in enumerate(self.items): # match to base item name within dir # (if it's a dir, snip last / for match) item_base = item.name.lower() - if item_base.endswith('/'): + if item_base.endswith("/"): item_base = item_base[:-1] item_base = os.path.basename(item_base) item_base = os.path.splitext(item_base)[0] if item_base.startswith(field_text): self.set_selected_item_index(i, set_field_text=False) break - + def handle_enter(self, shift_pressed, alt_pressed, ctrl_pressed): "handle Enter key, return False if rest of handle_input should continue" # if selected item is already in text field, pick it field_text = self.field_texts[self.active_field] selected_item = self.get_selected_item() - if field_text.strip() == '': + if field_text.strip() == "": self.field_texts[self.active_field] = field_text = selected_item.name return True if field_text == selected_item.name: @@ -459,9 +478,13 @@ class ChooserDialog(UIDialog): self.field_texts[self.active_field] = selected_item.name return True # special case for parent dir .. - if self.directory_aware and field_text == self.current_dir and selected_item.name == '..': + if ( + self.directory_aware + and field_text == self.current_dir + and selected_item.name == ".." + ): self.first_selection_made = True - return self.change_current_dir('..') + return self.change_current_dir("..") if self.directory_aware and os.path.isdir(field_text): self.first_selection_made = True return self.change_current_dir(field_text) @@ -470,13 +493,13 @@ class ChooserDialog(UIDialog): # if a file, change to its dir and select it if self.directory_aware and file_dir_name != self.current_dir: if self.change_current_dir(file_dir_name): - for i,item in enumerate(self.items): + for i, item in enumerate(self.items): if item.name == field_text: self.set_selected_item_index(i) item.picked(self) return True return False - + def render(self): UIDialog.render(self) if self.show_preview_image and self.preview_renderable.texture: diff --git a/ui_colors.py b/ui_colors.py index ade2691..54aac95 100644 --- a/ui_colors.py +++ b/ui_colors.py @@ -1,6 +1,6 @@ - class UIColors: "color indices for UI (c64 original) palette" + white = 2 lightgrey = 16 medgrey = 13 diff --git a/ui_console.py b/ui_console.py index 075a78d..e8bb848 100644 --- a/ui_console.py +++ b/ui_console.py @@ -1,46 +1,45 @@ import os -import sdl2 from math import ceil -from ui_element import UIElement -from art import UV_FLIPY -from key_shifts import SHIFT_MAP - -from image_convert import ImageConverter -from palette import PaletteFromFile - -from image_export import export_still_image, export_animation - -from PIL import Image +import sdl2 # imports for console execution namespace - be careful! -from OpenGL import GL +from art import UV_FLIPY +from image_convert import ImageConverter +from image_export import export_animation, export_still_image +from key_shifts import SHIFT_MAP +from palette import PaletteFromFile +from ui_element import UIElement +CONSOLE_HISTORY_FILENAME = "console_history" -CONSOLE_HISTORY_FILENAME = 'console_history' class ConsoleCommand: "parent class for console commands" - description = '[Enter a description for this command!]' + + description = "[Enter a description for this command!]" + def execute(console, args): - return 'Test command executed.' + return "Test command executed." class QuitCommand(ConsoleCommand): - description = 'Quit Playscii.' + description = "Quit Playscii." + def execute(console, args): console.ui.app.should_quit = True class SaveCommand(ConsoleCommand): - description = 'Save active art, under new filename if given.' + description = "Save active art, under new filename if given." + def execute(console, args): # save currently active file art = console.ui.active_art # set new filename if given if len(args) > 0: old_filename = art.filename - art.set_filename(' '.join(args)) + art.set_filename(" ".join(args)) art.save_to_file() console.ui.app.load_art_for_edit(old_filename) console.ui.set_active_art_by_filename(art.filename) @@ -50,71 +49,88 @@ class SaveCommand(ConsoleCommand): class OpenCommand(ConsoleCommand): - description = 'Open art with given filename.' + description = "Open art with given filename." + def execute(console, args): if len(args) == 0: - return 'Usage: open [art filename]' - filename = ' '.join(args) + return "Usage: open [art filename]" + filename = " ".join(args) console.ui.app.load_art_for_edit(filename) + class RevertArtCommand(ConsoleCommand): - description = 'Revert active art to last saved version.' + description = "Revert active art to last saved version." + def execute(console, args): console.ui.app.revert_active_art() + class LoadPaletteCommand(ConsoleCommand): - description = 'Set the given color palette as active.' + description = "Set the given color palette as active." + def execute(console, args): if len(args) == 0: - return 'Usage: pal [palette filename]' - filename = ' '.join(args) + return "Usage: pal [palette filename]" + filename = " ".join(args) # load AND set palette = console.ui.app.load_palette(filename) console.ui.active_art.set_palette(palette) console.ui.popup.set_active_palette(palette) + class LoadCharSetCommand(ConsoleCommand): - description = 'Set the given character set as active.' + description = "Set the given character set as active." + def execute(console, args): if len(args) == 0: - return 'Usage: char [character set filename]' - filename = ' '.join(args) + return "Usage: char [character set filename]" + filename = " ".join(args) charset = console.ui.app.load_charset(filename) console.ui.active_art.set_charset(charset) console.ui.popup.set_active_charset(charset) + class ImageExportCommand(ConsoleCommand): - description = 'Export active art as PNG image.' + description = "Export active art as PNG image." + def execute(console, args): export_still_image(console.ui.app, console.ui.active_art) + class AnimExportCommand(ConsoleCommand): - description = 'Export active art as animated GIF image.' + description = "Export active art as animated GIF image." + def execute(console, args): export_animation(console.ui.app, console.ui.active_art) + class ConvertImageCommand(ConsoleCommand): - description = 'Convert given bitmap image to current character set + color palette.' + description = "Convert given bitmap image to current character set + color palette." + def execute(console, args): if len(args) == 0: - return 'Usage: conv [image filename]' - image_filename = ' '.join(args) + return "Usage: conv [image filename]" + image_filename = " ".join(args) ImageConverter(console.ui.app, image_filename, console.ui.active_art) console.ui.app.update_window_title() + class OverlayImageCommand(ConsoleCommand): - description = 'Draw given bitmap image over active art document.' + description = "Draw given bitmap image over active art document." + def execute(console, args): if len(args) == 0: - return 'Usage: img [image filename]' - image_filename = ' '.join(args) + return "Usage: img [image filename]" + image_filename = " ".join(args) console.ui.app.set_overlay_image(image_filename) + class ImportCommand(ConsoleCommand): - description = 'Import file using an ArtImport class' + description = "Import file using an ArtImport class" + def execute(console, args): if len(args) < 2: - return 'Usage: imp [ArtImporter class name] [filename]' + return "Usage: imp [ArtImporter class name] [filename]" importers = console.ui.app.get_importers() importer_classname, filename = args[0], args[1] importer_class = None @@ -127,11 +143,13 @@ class ImportCommand(ConsoleCommand): console.ui.app.log("Couldn't find file %s" % filename) importer = importer_class(console.ui.app, filename) + class ExportCommand(ConsoleCommand): - description = 'Export current art using an ArtExport class' + description = "Export current art using an ArtExport class" + def execute(console, args): if len(args) < 2: - return 'Usage: exp [ArtExporter class name] [filename]' + return "Usage: exp [ArtExporter class name] [filename]" exporters = console.ui.app.get_exporters() exporter_classname, filename = args[0], args[1] exporter_class = None @@ -142,116 +160,134 @@ class ExportCommand(ConsoleCommand): console.ui.app.log("Couldn't find exporter class %s" % exporter_classname) exporter = exporter_class(console.ui.app, filename) + class PaletteFromImageCommand(ConsoleCommand): - description = 'Convert given image into a palette file.' + description = "Convert given image into a palette file." + def execute(console, args): if len(args) == 0: - return 'Usage: getpal [image filename]' - src_filename = ' '.join(args) + return "Usage: getpal [image filename]" + src_filename = " ".join(args) new_pal = PaletteFromFile(console.ui.app, src_filename, src_filename) if not new_pal.init_success: return - #console.ui.app.load_palette(new_pal.filename) + # console.ui.app.load_palette(new_pal.filename) console.ui.app.palettes.append(new_pal) console.ui.active_art.set_palette(new_pal) console.ui.popup.set_active_palette(new_pal) + class SetGameDirCommand(ConsoleCommand): - description = 'Load game from the given folder.' + description = "Load game from the given folder." + def execute(console, args): if len(args) == 0: - return 'Usage: setgame [game dir name]' - game_dir_name = ' '.join(args) + return "Usage: setgame [game dir name]" + game_dir_name = " ".join(args) console.ui.app.gw.set_game_dir(game_dir_name, True) + class LoadGameStateCommand(ConsoleCommand): - description = 'Load the given game state save file.' + description = "Load the given game state save file." + def execute(console, args): if len(args) == 0: - return 'Usage: game [game state filename]' - gs_name = ' '.join(args) + return "Usage: game [game state filename]" + gs_name = " ".join(args) console.ui.app.gw.load_game_state(gs_name) + class SaveGameStateCommand(ConsoleCommand): - description = 'Save the current game state as the given filename.' + description = "Save the current game state as the given filename." + def execute(console, args): "Usage: savegame [game state filename]" - gs_name = ' '.join(args) + gs_name = " ".join(args) console.ui.app.gw.save_to_file(gs_name) + class SpawnObjectCommand(ConsoleCommand): - description = 'Spawn an object of the given class name.' + description = "Spawn an object of the given class name." + def execute(console, args): if len(args) == 0: - return 'Usage: spawn [class name]' - class_name = ' '.join(args) + return "Usage: spawn [class name]" + class_name = " ".join(args) console.ui.app.gw.spawn_object_of_class(class_name) + class CommandListCommand(ConsoleCommand): - description = 'Show the list of console commands.' + description = "Show the list of console commands." + def execute(console, args): # TODO: print a command with usage if available - console.ui.app.log('Commands:') + console.ui.app.log("Commands:") # alphabetize command list command_list = list(commands.keys()) command_list.sort() for command in command_list: desc = commands[command].description - console.ui.app.log(' %s - %s' % (command, desc)) + console.ui.app.log(" %s - %s" % (command, desc)) + class RunArtScriptCommand(ConsoleCommand): - description = 'Run art script with given filename on active art.' + description = "Run art script with given filename on active art." + def execute(console, args): if len(args) == 0: - return 'Usage: src [art script filename]' - filename = ' '.join(args) + return "Usage: src [art script filename]" + filename = " ".join(args) console.ui.active_art.run_script(filename) + class RunEveryArtScriptCommand(ConsoleCommand): - description = 'Run art script with given filename on active art at given rate.' + description = "Run art script with given filename on active art at given rate." + def execute(console, args): if len(args) < 2: - return 'Usage: srcev [rate] [art script filename]' + return "Usage: srcev [rate] [art script filename]" rate = float(args[0]) - filename = ' '.join(args[1:]) + filename = " ".join(args[1:]) console.ui.active_art.run_script_every(filename, rate) # hide so user can immediately see what script is doing console.hide() + class StopArtScriptsCommand(ConsoleCommand): - description = 'Stop all actively running art scripts.' + description = "Stop all actively running art scripts." + def execute(console, args): console.ui.active_art.stop_all_scripts() + # map strings to command classes for ConsoleUI.parse commands = { - 'exit': QuitCommand, - 'quit': QuitCommand, - 'save': SaveCommand, - 'open': OpenCommand, - 'char': LoadCharSetCommand, - 'pal': LoadPaletteCommand, - 'imgexp': ImageExportCommand, - 'animexport': AnimExportCommand, - 'conv': ConvertImageCommand, - 'getpal': PaletteFromImageCommand, - 'setgame': SetGameDirCommand, - 'game': LoadGameStateCommand, - 'savegame': SaveGameStateCommand, - 'spawn': SpawnObjectCommand, - 'help': CommandListCommand, - 'scr': RunArtScriptCommand, - 'screv': RunEveryArtScriptCommand, - 'scrstop': StopArtScriptsCommand, - 'revert': RevertArtCommand, - 'img': OverlayImageCommand, - 'imp': ImportCommand, - 'exp': ExportCommand + "exit": QuitCommand, + "quit": QuitCommand, + "save": SaveCommand, + "open": OpenCommand, + "char": LoadCharSetCommand, + "pal": LoadPaletteCommand, + "imgexp": ImageExportCommand, + "animexport": AnimExportCommand, + "conv": ConvertImageCommand, + "getpal": PaletteFromImageCommand, + "setgame": SetGameDirCommand, + "game": LoadGameStateCommand, + "savegame": SaveGameStateCommand, + "spawn": SpawnObjectCommand, + "help": CommandListCommand, + "scr": RunArtScriptCommand, + "screv": RunEveryArtScriptCommand, + "scrstop": StopArtScriptsCommand, + "revert": RevertArtCommand, + "img": OverlayImageCommand, + "imp": ImportCommand, + "exp": ExportCommand, } class ConsoleUI(UIElement): - visible = False snap_top = True snap_left = True @@ -260,18 +296,18 @@ class ConsoleUI(UIElement): # how long (seconds) to shift/fade into view when invoked show_anim_time = 0.75 bg_alpha = 0.75 - prompt = '>' + prompt = ">" # _ ish char bottom_line_char_index = 76 right_margin = 3 # transient, but must be set here b/c UIElement.init calls reset_art - current_line = '' + current_line = "" game_mode_visible = True all_modes_visible = True - + def __init__(self, ui): self.bg_color_index = ui.colors.darkgrey - self.highlight_color = 8 # yellow + self.highlight_color = 8 # yellow UIElement.__init__(self, ui) # state stuff for console move/fade self.alpha = 0 @@ -283,26 +319,28 @@ class ConsoleUI(UIElement): self.last_lines = [] self.history_filename = self.ui.app.config_dir + CONSOLE_HISTORY_FILENAME if os.path.exists(self.history_filename): - self.history_file = open(self.history_filename, 'r') + self.history_file = open(self.history_filename) try: self.command_history = self.history_file.readlines() except: self.command_history = [] - self.history_file = open(self.history_filename, 'a') + self.history_file = open(self.history_filename, "a") else: - self.history_file = open(self.history_filename, 'w+') + self.history_file = open(self.history_filename, "w+") self.command_history = [] self.history_index = 0 # junk data in last user line so it changes on first update - self.last_user_line = 'test' + self.last_user_line = "test" # max line length = width of console minus prompt + _ self.max_line_length = int(self.art.width) - self.right_margin - + def reset_art(self): self.width = ceil(self.ui.width_tiles * self.ui.scale) # % of screen must take aspect into account inv_aspect = self.ui.app.window_height / self.ui.app.window_width - self.height = int(self.ui.height_tiles * self.height_screen_pct * inv_aspect * self.ui.scale) + self.height = int( + self.ui.height_tiles * self.height_screen_pct * inv_aspect * self.ui.scale + ) # dim background self.renderable.bg_alpha = self.bg_alpha # must resize here, as window width will vary @@ -311,43 +349,43 @@ class ConsoleUI(UIElement): self.text_color = self.ui.palette.lightest_index self.clear() # truncate current user line if it's too long for new width - self.current_line = self.current_line[:self.max_line_length] - #self.update_user_line() + self.current_line = self.current_line[: self.max_line_length] + # self.update_user_line() # empty log lines so they refresh from app - self.last_user_line = 'XXtestXX' + self.last_user_line = "XXtestXX" self.last_lines = [] - + def toggle(self): if self.visible: self.hide() else: self.show() - + def show(self): self.visible = True self.target_alpha = 1 self.target_y = 1 self.ui.menu_bar.visible = False self.ui.pulldown.visible = False - + def hide(self): self.target_alpha = 0 self.target_y = 2 if self.ui.app.can_edit: self.ui.menu_bar.visible = True - + def update_loc(self): # TODO: this lerp is super awful, simpler way based on dt? # TODO: use self.show_anim_time instead of this garbage! speed = 0.25 - + if self.y > self.target_y: self.y -= speed elif self.y < self.target_y: self.y += speed if abs(self.y - self.target_y) < speed: self.y = self.target_y - + if self.alpha > self.target_alpha: self.alpha -= speed / 2 elif self.alpha < self.target_alpha: @@ -356,35 +394,44 @@ class ConsoleUI(UIElement): self.alpha = self.target_alpha if self.alpha == 0: self.visible = False - + self.renderable.y = self.y self.renderable.alpha = self.alpha - + def clear(self): self.art.clear_frame_layer(0, 0, self.bg_color_index) # line -1 is always a line of ____________ for x in range(self.width): - self.art.set_tile_at(0, 0, x, -1, self.bottom_line_char_index, self.text_color, None, UV_FLIPY) - + self.art.set_tile_at( + 0, + 0, + x, + -1, + self.bottom_line_char_index, + self.text_color, + None, + UV_FLIPY, + ) + def update_user_line(self): "draw current user input on second to last line, with >_ prompt" # clear entire user line first - self.art.write_string(0, 0, 0, -2, ' ' * self.width, self.text_color) - self.art.write_string(0, 0, 0, -2, '%s ' % self.prompt, self.text_color) + self.art.write_string(0, 0, 0, -2, " " * self.width, self.text_color) + self.art.write_string(0, 0, 0, -2, "%s " % self.prompt, self.text_color) # if first item of line is a valid command, change its color items = self.current_line.split() if len(items) > 0 and items[0] in commands: self.art.write_string(0, 0, 2, -2, items[0], self.highlight_color) offset = 2 + len(items[0]) + 1 - args = ' '.join(items[1:]) + args = " ".join(items[1:]) self.art.write_string(0, 0, offset, -2, args, self.text_color) else: self.art.write_string(0, 0, 2, -2, self.current_line, self.text_color) # draw underscore for caret at end of input string x = len(self.prompt) + len(self.current_line) + 1 - i = self.ui.charset.get_char_index('_') + i = self.ui.charset.get_char_index("_") self.art.set_char_index_at(0, 0, x, -2, i) - + def update_log_lines(self): "update art from log lines" log_index = -1 @@ -395,10 +442,10 @@ class ConsoleUI(UIElement): break # trim line to width of console if len(line) >= self.max_line_length: - line = line[:self.max_line_length] + line = line[: self.max_line_length] self.art.write_string(0, 0, 1, y, line, self.text_color) log_index -= 1 - + def update(self): "update our Art with the current console log lines + user input" self.update_loc() @@ -423,44 +470,47 @@ class ConsoleUI(UIElement): # update user line independently of log, it changes at a different rate if user_input_changed: self.update_user_line() - + def visit_command_history(self, index): if len(self.command_history) == 0: return self.history_index = index self.history_index %= len(self.command_history) self.current_line = self.command_history[self.history_index].strip() - + def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed): "handles a key from Application.input" keystr = sdl2.SDL_GetKeyName(key).decode() # TODO: get console bound key from InputLord, detect that instead of # hard-coded backquote - if keystr == '`': + if keystr == "`": self.toggle() return - elif keystr == 'Return': - line = '%s %s' % (self.prompt, self.current_line) + elif keystr == "Return": + line = "%s %s" % (self.prompt, self.current_line) self.ui.app.log(line) # if command is same as last, don't repeat it - if len(self.command_history) == 0 or (len(self.command_history) > 0 and self.current_line != self.command_history[-1]): + if len(self.command_history) == 0 or ( + len(self.command_history) > 0 + and self.current_line != self.command_history[-1] + ): # don't add blank lines to history if self.current_line.strip(): self.command_history.append(self.current_line) - self.history_file.write(self.current_line + '\n') + self.history_file.write(self.current_line + "\n") self.parse(self.current_line) - self.current_line = '' + self.current_line = "" self.history_index = 0 - elif keystr == 'Tab': + elif keystr == "Tab": # TODO: autocomplete (commands, filenames) pass - elif keystr == 'Up': + elif keystr == "Up": # page back through command history self.visit_command_history(self.history_index - 1) - elif keystr == 'Down': + elif keystr == "Down": # page forward through command history self.visit_command_history(self.history_index + 1) - elif keystr == 'Backspace' and len(self.current_line) > 0: + elif keystr == "Backspace" and len(self.current_line) > 0: # alt-backspace: delete to last delimiter, eg periods if alt_pressed: # "index to delete to" @@ -472,7 +522,7 @@ class ConsoleUI(UIElement): if delete_index > -1: self.current_line = self.current_line[:delete_index] else: - self.current_line = '' + self.current_line = "" # user is bailing on whatever they were typing, # reset position in cmd history self.history_index = 0 @@ -481,18 +531,18 @@ class ConsoleUI(UIElement): if len(self.current_line) == 0: # same as above: reset position in cmd history self.history_index = 0 - elif keystr == 'Space': - keystr = ' ' + elif keystr == "Space": + keystr = " " # ignore any other non-character keys if len(keystr) > 1: return if keystr.isalpha() and not shift_pressed: keystr = keystr.lower() elif not keystr.isalpha() and shift_pressed: - keystr = SHIFT_MAP.get(keystr, '') + keystr = SHIFT_MAP.get(keystr, "") if len(self.current_line) < self.max_line_length: self.current_line += keystr - + def parse(self, line): # is line in a list of know commands? if so, handle it. items = line.split() @@ -511,28 +561,32 @@ class ConsoleUI(UIElement): camera = app.camera art = ui.active_art player = app.gw.player - sel = None if len(app.gw.selected_objects) == 0 else app.gw.selected_objects[0] + sel = ( + None + if len(app.gw.selected_objects) == 0 + else app.gw.selected_objects[0] + ) world = app.gw hud = app.gw.hud # special handling of assignment statements, eg x = 3: # detect strings that pattern-match, send them to exec(), # send all other strings to eval() - eq_index = line.find('=') - is_assignment = eq_index != -1 and line[eq_index+1] != '=' + eq_index = line.find("=") + is_assignment = eq_index != -1 and line[eq_index + 1] != "=" if is_assignment: exec(line) else: output = str(eval(line)) except Exception as e: # try to output useful error text - output = '%s: %s' % (e.__class__.__name__, str(e)) + output = "%s: %s" % (e.__class__.__name__, str(e)) # commands CAN return None, so only log if there's something - if output and output != 'None': + if output and output != "None": self.ui.app.log(output) - + def destroy(self): self.history_file.close() # delimiters - alt-backspace deletes to most recent one of these -delimiters = [' ', '.', ')', ']', ',', '_'] +delimiters = [" ", ".", ")", "]", ",", "_"] diff --git a/ui_dialog.py b/ui_dialog.py index 24f839c..d8fca14 100644 --- a/ui_dialog.py +++ b/ui_dialog.py @@ -1,49 +1,57 @@ import platform -import sdl2 from collections import namedtuple -from ui_element import UIElement -from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT -from ui_colors import UIColors +import sdl2 from key_shifts import SHIFT_MAP +from ui_button import TEXT_CENTER, UIButton +from ui_colors import UIColors +from ui_element import UIElement - -Field = namedtuple('Field', ['label', # text label for field - 'type', # supported: str int float bool - 'width', # width in tiles of the field - 'oneline']) # label and field drawn on same line +Field = namedtuple( + "Field", + [ + "label", # text label for field + "type", # supported: str int float bool + "width", # width in tiles of the field + "oneline", + ], +) # label and field drawn on same line # "null" field type that tells UI drawing to skip it -class SkipFieldType: pass +class SkipFieldType: + pass + class ConfirmButton(UIButton): - caption = 'Confirm' + caption = "Confirm" caption_justify = TEXT_CENTER width = len(caption) + 2 dimmed_fg_color = UIColors.lightgrey dimmed_bg_color = UIColors.white + class CancelButton(ConfirmButton): - caption = 'Cancel' + caption = "Cancel" width = len(caption) + 2 + class OtherButton(ConfirmButton): "button for 3rd option in some dialogs, eg Don't Save" - caption = 'Other' + + caption = "Other" width = len(caption) + 2 visible = False class UIDialog(UIElement): - tile_width, tile_height = 40, 8 # extra lines added to height beyond contents length extra_lines = 0 fg_color = UIColors.black bg_color = UIColors.white - title = 'Test Dialog Box' + title = "Test Dialog Box" # string message not tied to a specific field message = None other_button_visible = False @@ -72,23 +80,25 @@ class UIDialog(UIElement): radio_true_char_index = 127 radio_false_char_index = 126 # field text set for bool fields with True value - true_field_text = 'x' + true_field_text = "x" # if True, field labels will redraw with fields after handling input always_redraw_labels = False - + def __init__(self, ui, options): self.ui = ui # apply options, eg passed in from UI.open_dialog - for k,v in options.items(): + for k, v in options.items(): setattr(self, k, v) self.confirm_button = ConfirmButton(self) self.other_button = OtherButton(self) self.cancel_button = CancelButton(self) + # handle caption overrides def caption_override(button, alt_caption): if alt_caption and button.caption != alt_caption: button.caption = alt_caption button.width = len(alt_caption) + 2 + caption_override(self.confirm_button, self.confirm_caption) caption_override(self.other_button, self.other_caption) caption_override(self.cancel_button, self.cancel_caption) @@ -98,18 +108,18 @@ class UIDialog(UIElement): self.buttons = [self.confirm_button, self.other_button, self.cancel_button] # populate fields with text self.field_texts = [] - for i,field in enumerate(self.fields): + for i, field in enumerate(self.fields): self.field_texts.append(self.get_initial_field_text(i)) # field cursor starts on self.active_field = 0 UIElement.__init__(self, ui) if self.ui.menu_bar and self.ui.menu_bar.active_menu_name: self.ui.menu_bar.close_active_menu() - + def get_initial_field_text(self, field_number): "subclasses specify a given field's initial text here" - return '' - + return "" + def get_height(self, msg_lines): "determine size based on contents (subclasses can use custom logic)" # base height = 4, titlebar + padding + buttons + padding @@ -125,7 +135,7 @@ class UIDialog(UIElement): h += self.y_spacing + 2 h += self.extra_lines return h - + def reset_art(self, resize=True, clear_buttons=True): # get_message splits into >1 line if too long msg_lines = self.get_message() if self.message else [] @@ -138,20 +148,19 @@ class UIDialog(UIElement): self.y = (self.tile_height * qh) / 2 # draw window self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color) - s = ' ' + self.title.ljust(self.tile_width - 1) + s = " " + self.title.ljust(self.tile_width - 1) # invert titlebar (if kb focus) fg = self.titlebar_fg_color bg = self.titlebar_bg_color - if not self is self.ui.keyboard_focus_element and \ - self is self.ui.active_dialog: + if self is not self.ui.keyboard_focus_element and self is self.ui.active_dialog: fg = self.fg_color bg = self.bg_color self.art.write_string(0, 0, 0, 0, s, fg, bg) # message if self.message: y = 2 - for i,line in enumerate(msg_lines): - self.art.write_string(0, 0, 2, y+i, line) + for i, line in enumerate(msg_lines): + self.art.write_string(0, 0, 2, y + i, line) # field caption(s) self.draw_fields() # position buttons @@ -167,7 +176,7 @@ class UIDialog(UIElement): # create field buttons so you can click em if clear_buttons: self.buttons = [self.confirm_button, self.other_button, self.cancel_button] - for i,field in enumerate(self.fields): + for i, field in enumerate(self.fields): # None-type field = just a label if field.type is None: continue @@ -175,27 +184,30 @@ class UIDialog(UIElement): field_button.field_number = i # field settings mean button can be in a variety of places field_button.width = 1 if field.type is bool else field.width - field_button.x = 2 if not field.oneline or field.type is bool else len(field.label) + 1 + field_button.x = ( + 2 if not field.oneline or field.type is bool else len(field.label) + 1 + ) field_button.y = self.get_field_y(i) if not field.oneline: field_button.y += 1 self.buttons.append(field_button) # draw buttons UIElement.reset_art(self) - + def update_drag(self, mouse_dx, mouse_dy): win_w, win_h = self.ui.app.window_width, self.ui.app.window_height self.x += (mouse_dx / win_w) * 2 self.y -= (mouse_dy / win_h) * 2 self.renderable.x, self.renderable.y = self.x, self.y - + def hovered(self): # mouse hover on focus - if (self.ui.app.mouse_dx or self.ui.app.mouse_dy) and \ - not self is self.ui.keyboard_focus_element: + if ( + self.ui.app.mouse_dx or self.ui.app.mouse_dy + ) and self is not self.ui.keyboard_focus_element: self.ui.keyboard_focus_element = self self.reset_art() - + def update(self): # redraw fields every update for cursor blink # (seems a waste, no real perf impact tho) @@ -206,25 +218,25 @@ class UIDialog(UIElement): bottom_y = self.tile_height - 1 # first clear any previous warnings self.art.clear_line(0, 0, bottom_y) - self.confirm_button.set_state('normal') + self.confirm_button.set_state("normal") # some dialogs use reason for warning + valid input if reason: fg = self.ui.error_color_index self.art.write_string(0, 0, 1, bottom_y, reason, fg) if not valid: - self.confirm_button.set_state('dimmed') + self.confirm_button.set_state("dimmed") UIElement.update(self) - + def get_message(self): # if a triple quoted string, split line breaks - msg = self.message.rstrip().split('\n') + msg = self.message.rstrip().split("\n") msg_lines = [] for line in msg: - if line != '': + if line != "": msg_lines.append(line) # TODO: split over multiple lines if too long return msg_lines - + def get_field_colors(self, index): "return FG and BG colors for field with given index" fg, bg = self.inactive_field_fg_color, self.inactive_field_bg_color @@ -232,16 +244,16 @@ class UIDialog(UIElement): if self is self.ui.keyboard_focus_element and index == self.active_field: fg, bg = self.active_field_fg_color, self.active_field_bg_color return fg, bg - + def get_field_label(self, field_index): "Subclasses can override to do custom label logic eg string formatting" return self.fields[field_index].label - + def draw_fields(self, with_labels=True): y = 2 if self.message: y += len(self.get_message()) + 1 - for i,field in enumerate(self.fields): + for i, field in enumerate(self.fields): if field.type is SkipFieldType: continue x = 2 @@ -256,7 +268,11 @@ class UIDialog(UIElement): # true/false ~ field text is 'x' field_true = self.field_texts[i] == self.true_field_text if is_radio: - char = self.radio_true_char_index if field_true else self.radio_false_char_index + char = ( + self.radio_true_char_index + if field_true + else self.radio_false_char_index + ) else: char = self.checkbox_char_index if field_true else 0 fg, bg = self.get_field_colors(i) @@ -275,19 +291,19 @@ class UIDialog(UIElement): else: y += 1 # draw field contents - if not field.type in [bool, None]: + if field.type not in [bool, None]: fg, bg = self.get_field_colors(i) text = self.field_texts[i] # caret for active field (if kb focus) if i == self.active_field and self is self.ui.keyboard_focus_element: blink_on = int(self.ui.app.get_elapsed_time() / 250) % 2 if blink_on: - text += '_' + text += "_" # pad with spaces to full width of field text = text.ljust(field.width) self.art.write_string(0, 0, x, y, text, fg, bg) y += self.y_spacing + 1 - + def get_field_y(self, field_index): "returns a Y value for where the given field (caption) should start" y = 2 @@ -300,7 +316,7 @@ class UIDialog(UIElement): else: y += self.y_spacing + 2 return y - + def get_toggled_bool_field(self, field_index): field_text = self.field_texts[field_index] on = field_text == self.true_field_text @@ -312,111 +328,121 @@ class UIDialog(UIElement): if not on: for i in group: if i != field_index: - self.field_texts[i] = ' ' + self.field_texts[i] = " " break # toggle checkbox if not radio_button: - return ' ' if on else self.true_field_text + return " " if on else self.true_field_text # only toggle radio button on; selecting others toggles it off elif on: return field_text else: return self.true_field_text - + def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed): keystr = sdl2.SDL_GetKeyName(key).decode() field = None - field_text = '' + field_text = "" if self.active_field < len(self.fields): field = self.fields[self.active_field] field_text = self.field_texts[self.active_field] # special case: shortcut 'D' for 3rd button if no field input - if len(self.fields) == 0 and keystr.lower() == 'd': + if len(self.fields) == 0 and keystr.lower() == "d": self.other_pressed() return - if keystr == '`' and not shift_pressed: + if keystr == "`" and not shift_pressed: self.ui.console.toggle() return # if list panel is up don't let user tab away lp = self.ui.edit_list_panel # only allow tab to focus shift IF list panel accepts it - if keystr == 'Tab' and lp.is_visible() and \ - lp.list_operation in lp.list_operations_allow_kb_focus: + if ( + keystr == "Tab" + and lp.is_visible() + and lp.list_operation in lp.list_operations_allow_kb_focus + ): self.ui.keyboard_focus_element = self.ui.edit_list_panel return - elif keystr == 'Return': + elif keystr == "Return": self.confirm_pressed() - elif keystr == 'Escape': + elif keystr == "Escape": self.cancel_pressed() # cycle through fields with up/down - elif keystr == 'Up' or (keystr == 'Tab' and shift_pressed): + elif keystr == "Up" or (keystr == "Tab" and shift_pressed): if len(self.fields) > 1: self.active_field -= 1 self.active_field %= len(self.fields) # skip over None-type fields aka dead labels - while self.fields[self.active_field].type is None or self.fields[self.active_field].type is SkipFieldType: + while ( + self.fields[self.active_field].type is None + or self.fields[self.active_field].type is SkipFieldType + ): self.active_field -= 1 self.active_field %= len(self.fields) return - elif keystr == 'Down' or keystr == 'Tab': + elif keystr == "Down" or keystr == "Tab": if len(self.fields) > 1: self.active_field += 1 self.active_field %= len(self.fields) - while self.fields[self.active_field].type is None or self.fields[self.active_field].type is SkipFieldType: + while ( + self.fields[self.active_field].type is None + or self.fields[self.active_field].type is SkipFieldType + ): self.active_field += 1 self.active_field %= len(self.fields) return - elif keystr == 'Backspace': - if len(field_text) == 0: - pass - # don't let user clear a bool value - # TODO: allow for checkboxes but not radio buttons - elif field and field.type is bool: + elif keystr == "Backspace": + if len(field_text) == 0 or field and field.type is bool: pass elif alt_pressed: # for file dialogs, delete back to last slash - last_slash = field_text[:-1].rfind('/') + last_slash = field_text[:-1].rfind("/") # on windows, recognize backslash as well - if platform.system() == 'Windows': - last_backslash = field_text[:-1].rfind('\\') + if platform.system() == "Windows": + last_backslash = field_text[:-1].rfind("\\") if last_backslash != -1 and last_slash != -1: last_slash = min(last_backslash, last_slash) if last_slash == -1: - field_text = '' + field_text = "" else: - field_text = field_text[:last_slash+1] + field_text = field_text[: last_slash + 1] else: field_text = field_text[:-1] - elif keystr == 'Space': + elif keystr == "Space": # if field.type is bool, toggle value if field.type is bool: field_text = self.get_toggled_bool_field(self.active_field) else: - field_text += ' ' + field_text += " " elif len(keystr) > 1: return # alphanumeric text input - elif field and not field.type is bool: + elif field and field.type is not bool: if field.type is str: if not shift_pressed: keystr = keystr.lower() if not keystr.isalpha() and shift_pressed: - keystr = SHIFT_MAP.get(keystr, '') - elif field.type is int and not keystr.isdigit() and keystr != '-': - return - # this doesn't guard against things like 0.00.001 - elif field.type is float and not keystr.isdigit() and keystr != '.' and keystr != '-': + keystr = SHIFT_MAP.get(keystr, "") + elif ( + field.type is int + and not keystr.isdigit() + and keystr != "-" + or field.type is float + and not keystr.isdigit() + and keystr != "." + and keystr != "-" + ): return field_text += keystr # apply new field text and redraw if field and (len(field_text) < field.width or field.type is bool): self.field_texts[self.active_field] = field_text self.draw_fields(self.always_redraw_labels) - + def is_input_valid(self): "subclasses that want to filter input put logic here" return True, None - + def dismiss(self): # let UI forget about us self.ui.active_dialog = None @@ -424,32 +450,33 @@ class UIDialog(UIElement): self.ui.keyboard_focus_element = None self.ui.refocus_keyboard() self.ui.elements.remove(self) - + def confirm_pressed(self): # subclasses do more here :] self.dismiss() - + def cancel_pressed(self): self.dismiss() - + def other_pressed(self): self.dismiss() class DialogFieldButton(UIButton): - "invisible button that provides clickability for input fields" - - caption = '' + + caption = "" # re-set by dialog constructor field_number = 0 never_draw = True - + def click(self): UIButton.click(self) self.element.active_field = self.field_number # toggle if a bool field if self.element.fields[self.field_number].type is bool: - self.element.field_texts[self.field_number] = self.element.get_toggled_bool_field(self.field_number) + self.element.field_texts[self.field_number] = ( + self.element.get_toggled_bool_field(self.field_number) + ) # redraw fields & labels self.element.draw_fields(self.element.always_redraw_labels) diff --git a/ui_edit_panel.py b/ui_edit_panel.py index 382339e..1606c54 100644 --- a/ui_edit_panel.py +++ b/ui_edit_panel.py @@ -1,17 +1,28 @@ import os -from ui_element import UIElement +from game_world import STATE_FILE_EXTENSION, TOP_GAME_DIR from ui_button import UIButton -from ui_game_dialog import LoadGameStateDialog, SaveGameStateDialog from ui_chooser_dialog import ScrollArrowButton from ui_colors import UIColors - -from game_world import TOP_GAME_DIR, STATE_FILE_EXTENSION -from ui_list_operations import LO_NONE, LO_SELECT_OBJECTS, LO_SET_SPAWN_CLASS, LO_LOAD_STATE, LO_SET_ROOM, LO_SET_ROOM_OBJECTS, LO_SET_OBJECT_ROOMS, LO_OPEN_GAME_DIR, LO_SET_ROOM_EDGE_WARP, LO_SET_ROOM_EDGE_OBJ, LO_SET_ROOM_CAMERA +from ui_element import UIElement +from ui_list_operations import ( + LO_LOAD_STATE, + LO_NONE, + LO_OPEN_GAME_DIR, + LO_SELECT_OBJECTS, + LO_SET_OBJECT_ROOMS, + LO_SET_ROOM, + LO_SET_ROOM_CAMERA, + LO_SET_ROOM_EDGE_OBJ, + LO_SET_ROOM_EDGE_WARP, + LO_SET_ROOM_OBJECTS, + LO_SET_SPAWN_CLASS, +) class GamePanel(UIElement): "base class of game edit UI panels" + tile_y = 5 game_mode_visible = True fg_color = UIColors.black @@ -22,7 +33,7 @@ class GamePanel(UIElement): support_keyboard_navigation = True support_scrolling = True keyboard_nav_offset = -2 - + def __init__(self, ui): self.ui = ui self.world = self.ui.app.gw @@ -30,60 +41,76 @@ class GamePanel(UIElement): self.buttons = [] self.create_buttons() self.keyboard_nav_index = 0 - - def create_buttons(self): pass + + def create_buttons(self): + pass + # label and main item draw functions - overridden in subclasses - def get_label(self): pass - def refresh_items(self): pass - + def get_label(self): + pass + + def refresh_items(self): + pass + # reset all buttons to default state def clear_buttons(self, button_list=None): buttons = button_list or self.buttons for button in buttons: self.reset_button(button) - + def reset_button(self, button): button.normal_fg_color = UIButton.normal_fg_color button.normal_bg_color = UIButton.normal_bg_color button.hovered_fg_color = UIButton.hovered_fg_color button.hovered_bg_color = UIButton.hovered_bg_color button.can_hover = True - + def highlight_button(self, button): button.normal_fg_color = UIButton.clicked_fg_color button.normal_bg_color = UIButton.clicked_bg_color button.hovered_fg_color = UIButton.clicked_fg_color button.hovered_bg_color = UIButton.clicked_bg_color button.can_hover = True - + def draw_titlebar(self): # only shade titlebar if panel has keyboard focus - fg = self.titlebar_fg if self is self.ui.keyboard_focus_element else self.fg_color - bg = self.titlebar_bg if self is self.ui.keyboard_focus_element else self.bg_color + fg = ( + self.titlebar_fg + if self is self.ui.keyboard_focus_element + else self.fg_color + ) + bg = ( + self.titlebar_bg + if self is self.ui.keyboard_focus_element + else self.bg_color + ) self.art.clear_line(0, 0, 0, fg, bg) label = self.get_label() if len(label) > self.tile_width: - label = label[:self.tile_width] + label = label[: self.tile_width] if self.text_left: self.art.write_string(0, 0, 0, 0, label) else: self.art.write_string(0, 0, -1, 0, label, None, None, True) - + def reset_art(self): self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color) self.draw_titlebar() self.refresh_items() UIElement.reset_art(self) - + def clicked(self, mouse_button): # always handle input, even if we didn't hit a button UIElement.clicked(self, mouse_button) return True - + def hovered(self): # mouse hover on focus - if self.ui.app.mouse_dx or self.ui.app.mouse_dy and \ - not self is self.ui.keyboard_focus_element: + if ( + self.ui.app.mouse_dx + or self.ui.app.mouse_dy + and self is not self.ui.keyboard_focus_element + ): self.ui.keyboard_focus_element = self if self.ui.active_dialog: self.ui.active_dialog.reset_art() @@ -93,16 +120,20 @@ class ListButton(UIButton): width = 28 clear_before_caption_draw = True + class ListScrollArrowButton(ScrollArrowButton): x = ListButton.width normal_bg_color = UIButton.normal_bg_color + class ListScrollUpArrowButton(ListScrollArrowButton): y = 1 + class ListScrollDownArrowButton(ListScrollArrowButton): up = False + class EditListPanel(GamePanel): tile_width = ListButton.width + 1 tile_y = 5 @@ -110,35 +141,38 @@ class EditListPanel(GamePanel): # height will change based on how many items in list tile_height = 30 snap_left = True - spawn_msg = 'Click anywhere in the world view to spawn a %s' + spawn_msg = "Click anywhere in the world view to spawn a %s" # transient state - titlebar = 'List titlebar' + titlebar = "List titlebar" items = [] # text helping user know how to bail - cancel_tip = 'ESC cancels' + cancel_tip = "ESC cancels" list_operation_labels = { - LO_NONE: 'Stuff:', - LO_SELECT_OBJECTS: 'Select objects:', - LO_SET_SPAWN_CLASS: 'Class to spawn:', - LO_LOAD_STATE: 'State to load:', - LO_SET_ROOM: 'Change room:', + LO_NONE: "Stuff:", + LO_SELECT_OBJECTS: "Select objects:", + LO_SET_SPAWN_CLASS: "Class to spawn:", + LO_LOAD_STATE: "State to load:", + LO_SET_ROOM: "Change room:", LO_SET_ROOM_OBJECTS: "Set objects for %s:", LO_SET_OBJECT_ROOMS: "Set rooms for %s:", - LO_OPEN_GAME_DIR: 'Open game:', - LO_SET_ROOM_EDGE_WARP: 'Set edge warp room/object:', - LO_SET_ROOM_EDGE_OBJ: 'Set edge bounds object:', - LO_SET_ROOM_CAMERA: 'Set room camera marker:' + LO_OPEN_GAME_DIR: "Open game:", + LO_SET_ROOM_EDGE_WARP: "Set edge warp room/object:", + LO_SET_ROOM_EDGE_OBJ: "Set edge bounds object:", + LO_SET_ROOM_CAMERA: "Set room camera marker:", } list_operations_allow_kb_focus = [ LO_SET_ROOM_EDGE_WARP, LO_SET_ROOM_EDGE_OBJ, - LO_SET_ROOM_CAMERA + LO_SET_ROOM_CAMERA, ] - + class ListItem: - def __init__(self, name, obj): self.name, self.obj = name, obj - def __str__(self): return self.name - + def __init__(self, name, obj): + self.name, self.obj = name, obj + + def __str__(self): + return self.name + def __init__(self, ui): # topmost index of items to show in view self.list_scroll_index = 0 @@ -149,41 +183,45 @@ class EditListPanel(GamePanel): for list_op in self.list_operation_labels: self.scroll_indices[list_op] = 0 # map list operations to list builder functions - self.list_functions = {LO_NONE: self.list_none, - LO_SELECT_OBJECTS: self.list_objects, - LO_SET_SPAWN_CLASS: self.list_classes, - LO_LOAD_STATE: self.list_states, - LO_SET_ROOM: self.list_rooms, - LO_SET_ROOM_OBJECTS: self.list_objects, - LO_SET_OBJECT_ROOMS: self.list_rooms, - LO_OPEN_GAME_DIR: self.list_games, - LO_SET_ROOM_EDGE_WARP: self.list_rooms_and_objects, - LO_SET_ROOM_EDGE_OBJ: self.list_objects, - LO_SET_ROOM_CAMERA: self.list_objects + self.list_functions = { + LO_NONE: self.list_none, + LO_SELECT_OBJECTS: self.list_objects, + LO_SET_SPAWN_CLASS: self.list_classes, + LO_LOAD_STATE: self.list_states, + LO_SET_ROOM: self.list_rooms, + LO_SET_ROOM_OBJECTS: self.list_objects, + LO_SET_OBJECT_ROOMS: self.list_rooms, + LO_OPEN_GAME_DIR: self.list_games, + LO_SET_ROOM_EDGE_WARP: self.list_rooms_and_objects, + LO_SET_ROOM_EDGE_OBJ: self.list_objects, + LO_SET_ROOM_CAMERA: self.list_objects, } # map list operations to "item clicked" functions - self.click_functions = {LO_SELECT_OBJECTS: self.select_object, - LO_SET_SPAWN_CLASS: self.set_spawn_class, - LO_LOAD_STATE: self.load_state, - LO_SET_ROOM: self.set_room, - LO_SET_ROOM_OBJECTS: self.set_room_object, - LO_SET_OBJECT_ROOMS: self.set_object_room, - LO_OPEN_GAME_DIR: self.open_game_dir, - LO_SET_ROOM_EDGE_WARP: self.set_room_edge_warp, - LO_SET_ROOM_EDGE_OBJ: self.set_room_bounds_obj, - LO_SET_ROOM_CAMERA: self.set_room_camera + self.click_functions = { + LO_SELECT_OBJECTS: self.select_object, + LO_SET_SPAWN_CLASS: self.set_spawn_class, + LO_LOAD_STATE: self.load_state, + LO_SET_ROOM: self.set_room, + LO_SET_ROOM_OBJECTS: self.set_room_object, + LO_SET_OBJECT_ROOMS: self.set_object_room, + LO_OPEN_GAME_DIR: self.open_game_dir, + LO_SET_ROOM_EDGE_WARP: self.set_room_edge_warp, + LO_SET_ROOM_EDGE_OBJ: self.set_room_bounds_obj, + LO_SET_ROOM_CAMERA: self.set_room_camera, } # separate lists for item buttons vs other controls self.list_buttons = [] # set when game resets self.should_reset_list = False GamePanel.__init__(self, ui) - + def create_buttons(self): def list_callback(item=None): - if not item: return + if not item: + return self.clicked_item(item) - for y in range(self.tile_height-1): + + for y in range(self.tile_height - 1): button = ListButton(self) button.y = y + 1 button.callback = list_callback @@ -198,32 +236,33 @@ class EditListPanel(GamePanel): # TODO: adjust height according to screen tile height self.down_button.y = self.tile_height - 1 self.buttons.append(self.down_button) - + def reset_art(self): GamePanel.reset_art(self) x = self.tile_width - 1 for y in range(1, self.tile_height): - self.art.set_tile_at(0, 0, x, y, self.scrollbar_shade_char, - UIColors.medgrey) - + self.art.set_tile_at( + 0, 0, x, y, self.scrollbar_shade_char, UIColors.medgrey + ) + def cancel(self): self.set_list_operation(LO_NONE) self.world.classname_to_spawn = None - + def scroll_list_up(self): if self.list_scroll_index > 0: self.list_scroll_index -= 1 - + def scroll_list_down(self): max_scroll = len(self.items) - self.tile_height - #max_scroll = len(self.element.items) - self.element.items_in_view + # max_scroll = len(self.element.items) - self.element.items_in_view if self.list_scroll_index <= max_scroll: self.list_scroll_index += 1 - + def clicked_item(self, item): # do thing appropriate to current list operation self.click_functions[self.list_operation](item) - + def wheel_moved(self, wheel_y): if wheel_y > 0: self.scroll_list_up() @@ -231,7 +270,7 @@ class EditListPanel(GamePanel): if wheel_y < 0: self.scroll_list_down() return True - + def set_list_operation(self, new_op): "changes list type and sets new items" if new_op == LO_LOAD_STATE and not self.world.game_dir: @@ -255,11 +294,14 @@ class EditListPanel(GamePanel): self.list_scroll_index = self.scroll_indices[self.list_operation] # keep in bounds if list size changed since last view self.list_scroll_index = min(self.list_scroll_index, len(self.items)) - + def get_label(self): - label = '%s (%s)' % (self.list_operation_labels[self.list_operation], self.cancel_tip) + label = "%s (%s)" % ( + self.list_operation_labels[self.list_operation], + self.cancel_tip, + ) # some labels contain variables - if '%s' in label: + if "%s" in label: if self.list_operation == LO_SET_ROOM_OBJECTS: if self.world.current_room: label %= self.world.current_room.name @@ -267,7 +309,7 @@ class EditListPanel(GamePanel): if len(self.world.selected_objects) == 1: label %= self.world.selected_objects[0].name return label - + def should_highlight(self, item): if self.list_operation == LO_SELECT_OBJECTS: return item.obj in self.world.selected_objects @@ -280,25 +322,30 @@ class EditListPanel(GamePanel): elif self.list_operation == LO_SET_ROOM: return self.world.current_room and item.name == self.world.current_room.name elif self.list_operation == LO_SET_ROOM_OBJECTS: - return self.world.current_room and item.name in self.world.current_room.objects + return ( + self.world.current_room and item.name in self.world.current_room.objects + ) elif self.list_operation == LO_SET_OBJECT_ROOMS: - return len(self.world.selected_objects) == 1 and item.name in self.world.selected_objects[0].rooms + return ( + len(self.world.selected_objects) == 1 + and item.name in self.world.selected_objects[0].rooms + ) return False - + def game_reset(self): self.should_reset_list = True - + def items_changed(self): "called by anything that changes the items list, eg object add/delete" self.items = self.list_functions[self.list_operation]() # change selected item index if it's OOB if self.keyboard_nav_index >= len(self.items): self.keyboard_nav_index = len(self.items) - 1 - + def refresh_items(self): - for i,b in enumerate(self.list_buttons): + for i, b in enumerate(self.list_buttons): if i >= len(self.items): - b.caption = '' + b.caption = "" b.cb_arg = None self.reset_button(b) b.can_hover = False @@ -306,7 +353,7 @@ class EditListPanel(GamePanel): index = self.list_scroll_index + i item = self.items[index] b.cb_arg = item - b.caption = item.name[:self.tile_width - 1] + b.caption = item.name[: self.tile_width - 1] b.can_hover = True # change button appearance if this item should remain # highlighted/selected @@ -315,7 +362,7 @@ class EditListPanel(GamePanel): else: self.reset_button(b) self.draw_buttons() - + def post_keyboard_navigate(self): # check for scrolling if len(self.items) <= len(self.list_buttons): @@ -336,7 +383,7 @@ class EditListPanel(GamePanel): elif self.keyboard_nav_index < 0: self.scroll_list_up() self.keyboard_nav_index += 1 - + def update(self): if self.should_reset_list: self.set_list_operation(self.list_operation) @@ -346,18 +393,18 @@ class EditListPanel(GamePanel): self.refresh_items() GamePanel.update(self) self.renderable.alpha = 1 if self is self.ui.keyboard_focus_element else 0.5 - + def is_visible(self): return GamePanel.is_visible(self) and self.list_operation != LO_NONE - + # # list functions # def list_classes(self): items = [] - base_class = self.world.modules['game_object'].GameObject + base_class = self.world.modules["game_object"].GameObject # get list of available classes from GameWorld - for classname,classdef in self.world._get_all_loaded_classes().items(): + for classname, classdef in self.world._get_all_loaded_classes().items(): # ignore non-GO classes, eg GameRoom, GameHUD if not issubclass(classdef, base_class): continue @@ -368,7 +415,7 @@ class EditListPanel(GamePanel): # sort classes alphabetically items.sort(key=lambda i: i.name) return items - + def list_objects(self): items = [] # include just-spawned objects too @@ -377,24 +424,27 @@ class EditListPanel(GamePanel): for obj in all_objects.values(): if obj.exclude_from_object_list: continue - if self.world.list_only_current_room_objects and not self.world.current_room.name in obj.rooms: + if ( + self.world.list_only_current_room_objects + and self.world.current_room.name not in obj.rooms + ): continue li = self.ListItem(obj.name, obj) items.append(li) # sort object names alphabetically items.sort(key=lambda i: i.name) return items - + def list_states(self): items = [] # list state files in current game dir for filename in os.listdir(self.world.game_dir): - if filename.endswith('.' + STATE_FILE_EXTENSION): + if filename.endswith("." + STATE_FILE_EXTENSION): li = self.ListItem(filename[:-3], None) items.append(li) items.sort(key=lambda i: i.name) return items - + def list_rooms(self): items = [] for room in self.world.rooms.values(): @@ -402,7 +452,7 @@ class EditListPanel(GamePanel): items.append(li) items.sort(key=lambda i: i.name) return items - + def list_games(self): def get_dirs(dirname): dirs = [] @@ -410,6 +460,7 @@ class EditListPanel(GamePanel): if os.path.isdir(dirname + filename): dirs.append(filename) return dirs + # get list of both app dir games and user dir games docs_game_dir = self.ui.app.documents_dir + TOP_GAME_DIR items = [] @@ -419,18 +470,18 @@ class EditListPanel(GamePanel): li = self.ListItem(game, None) items.append(li) return items - + def list_rooms_and_objects(self): items = self.list_rooms() # prefix room names with "ROOM:" - for i,item in enumerate(items): - item.name = 'ROOM: %s' % item.name + for i, item in enumerate(items): + item.name = "ROOM: %s" % item.name items += self.list_objects() return items - + def list_none(self): return [] - + # # "clicked list item" functions # @@ -443,25 +494,27 @@ class EditListPanel(GamePanel): else: self.world.deselect_all() self.world.select_object(item.obj, force=True) - + def set_spawn_class(self, item): # set this class to be the one spawned when GameWorld is clicked self.world.classname_to_spawn = item.name - self.ui.message_line.post_line(self.spawn_msg % self.world.classname_to_spawn, 5) - + self.ui.message_line.post_line( + self.spawn_msg % self.world.classname_to_spawn, 5 + ) + def load_state(self, item): self.world.load_game_state(item.name) - + def set_room(self, item): self.world.change_room(item.name) - + def set_room_object(self, item): # add/remove object from current room if item.name in self.world.current_room.objects: self.world.current_room.remove_object_by_name(item.name) else: self.world.current_room.add_object_by_name(item.name) - + def set_object_room(self, item): # UI can only show a single object's rooms, do nothing if many selected if len(self.world.selected_objects) != 1: @@ -473,20 +526,20 @@ class EditListPanel(GamePanel): room.remove_object(obj) else: room.add_object(obj) - + def open_game_dir(self, item): self.world.set_game_dir(item.name, True) - + def set_room_edge_warp(self, item): dialog = self.ui.active_dialog dialog.field_texts[dialog.active_field] = item.obj.name self.ui.keyboard_focus_element = dialog - + def set_room_bounds_obj(self, item): dialog = self.ui.active_dialog dialog.field_texts[dialog.active_field] = item.obj.name self.ui.keyboard_focus_element = dialog - + def set_room_camera(self, item): dialog = self.ui.active_dialog dialog.field_texts[dialog.active_field] = item.obj.name diff --git a/ui_element.py b/ui_element.py index ccc0827..5ede0ce 100644 --- a/ui_element.py +++ b/ui_element.py @@ -1,15 +1,12 @@ import time -import numpy as np from math import ceil import vector from art import Art from renderable import TileRenderable -from renderable_line import LineRenderable -from ui_button import UIButton + class UIElement: - # size, in tiles tile_width, tile_height = 1, 1 snap_top, snap_bottom, snap_left, snap_right = False, False, False, False @@ -35,13 +32,20 @@ class UIElement: game_mode_visible = False all_modes_visible = False keyboard_nav_offset = 0 - + def __init__(self, ui): self.ui = ui self.hovered_buttons = [] # generate a unique name - art_name = '%s_%s' % (int(time.time()), self.__class__.__name__) - self.art = UIArt(art_name, self.ui.app, self.ui.charset, self.ui.palette, self.tile_width, self.tile_height) + art_name = "%s_%s" % (int(time.time()), self.__class__.__name__) + self.art = UIArt( + art_name, + self.ui.app, + self.ui.charset, + self.ui.palette, + self.tile_width, + self.tile_height, + ) self.renderable = UIRenderable(self.ui.app, self.art) self.renderable.ui = self.ui # some elements add their own renderables before calling this @@ -53,13 +57,13 @@ class UIElement: self.reset_loc() if self.support_keyboard_navigation: self.keyboard_nav_index = 0 - + def is_inside(self, x, y): "returns True if given point is inside this element's bounds" w = self.tile_width * self.art.quad_width h = self.tile_height * self.art.quad_height - return self.x <= x <= self.x+w and self.y >= y >= self.y-h - + return self.x <= x <= self.x + w and self.y >= y >= self.y - h + def is_inside_button(self, x, y, button): "returns True if given point is inside the given button's bounds" aqw, aqh = self.art.quad_width, self.art.quad_height @@ -69,30 +73,30 @@ class UIElement: bxmin, bymin = self.x + bx, self.y - by bxmax, bymax = bxmin + bw, bymin - bh return bxmin <= x <= bxmax and bymin >= y >= bymax - + def reset_art(self): """ runs on init and resize, restores state. """ self.draw_buttons() - + def draw_buttons(self): for button in self.buttons: if button.visible: button.draw() - + def hovered(self): - self.log_event('hovered') - + self.log_event("hovered") + def unhovered(self): - self.log_event('unhovered') - + self.log_event("unhovered") + def wheel_moved(self, wheel_y): handled = False return handled - + def clicked(self, mouse_button): - self.log_event('clicked', mouse_button) + self.log_event("clicked", mouse_button) # return if a button did something handled = False # tell any hovered buttons they've been clicked @@ -116,9 +120,9 @@ class UIElement: if self.always_consume_input: return True return handled - + def unclicked(self, mouse_button): - self.log_event('unclicked', mouse_button) + self.log_event("unclicked", mouse_button) handled = False for b in self.hovered_buttons: b.unclick() @@ -126,21 +130,27 @@ class UIElement: if self.always_consume_input: return True return handled - + def log_event(self, event_type, mouse_button=None): - mouse_button = mouse_button or '[n/a]' + mouse_button = mouse_button or "[n/a]" if self.ui.logg: - self.ui.app.log('UIElement: %s %s with mouse button %s' % (self.__class__.__name__, event_type, mouse_button)) - + self.ui.app.log( + "UIElement: %s %s with mouse button %s" + % (self.__class__.__name__, event_type, mouse_button) + ) + def is_visible(self): if self.all_modes_visible: return self.visible - elif not self.ui.app.game_mode and self.game_mode_visible: - return False - elif self.ui.app.game_mode and not self.game_mode_visible: + elif ( + not self.ui.app.game_mode + and self.game_mode_visible + or self.ui.app.game_mode + and not self.game_mode_visible + ): return False return self.visible - + def reset_loc(self): if self.snap_top: self.y = 1 @@ -155,7 +165,7 @@ class UIElement: elif self.tile_x: self.x = -1 + (self.tile_x * self.art.quad_width) self.renderable.x, self.renderable.y = self.x, self.y - + def keyboard_navigate(self, move_x, move_y): if not self.support_keyboard_navigation: return @@ -174,7 +184,10 @@ class UIElement: self.keyboard_nav_index %= len(self.buttons) + self.keyboard_nav_offset tries = 0 # recognize two different kinds of inactive items: empty caption and dim state - while tries < len(self.buttons) and (self.buttons[self.keyboard_nav_index].caption == '' or self.buttons[self.keyboard_nav_index].state == 'dimmed'): + while tries < len(self.buttons) and ( + self.buttons[self.keyboard_nav_index].caption == "" + or self.buttons[self.keyboard_nav_index].state == "dimmed" + ): # move_y might be zero, give it a direction to avoid infinite loop # if menu item 0 is dimmed self.keyboard_nav_index += move_y or 1 @@ -184,23 +197,23 @@ class UIElement: return self.post_keyboard_navigate() self.update_keyboard_hover() - + def update_keyboard_hover(self): if not self.support_keyboard_navigation: return - for i,button in enumerate(self.buttons): + for i, button in enumerate(self.buttons): # don't higlhight if this panel doesn't have focus if self.keyboard_nav_index == i and self is self.ui.keyboard_focus_element: - button.set_state('hovered') - elif button.state != 'dimmed': - button.set_state('normal') - + button.set_state("hovered") + elif button.state != "dimmed": + button.set_state("normal") + def keyboard_select_item(self): if not self.support_keyboard_navigation: return button = self.buttons[self.keyboard_nav_index] # don't allow selecting dimmed buttons - if button.state == 'dimmed': + if button.state == "dimmed": return # check for None; cb_arg could be 0 if button.cb_arg is not None: @@ -208,11 +221,11 @@ class UIElement: else: button.callback() return button - + def post_keyboard_navigate(self): # subclasses can put stuff here to check scrolling etc pass - + def update(self): "runs every frame, checks button states" # this is very similar to UI.update, implying an alternative structure @@ -225,16 +238,20 @@ class UIElement: for b in self.buttons: # element.clicked might have been set it non-hoverable, acknowledge # its hoveredness here so it can unhover correctly - if b.visible and (b.can_hover or b.state == 'clicked') and self.is_inside_button(mx, my, b): + if ( + b.visible + and (b.can_hover or b.state == "clicked") + and self.is_inside_button(mx, my, b) + ): self.hovered_buttons.append(b) - if not b in was_hovering: + if b not in was_hovering: b.hover() for b in was_hovering: - if not b in self.hovered_buttons: + if b not in self.hovered_buttons: b.unhover() # tiles might have just changed self.art.update() - + def render(self): # ("is visible" check happens in UI.render, calls our is_visible) # render drop shadow first @@ -246,7 +263,7 @@ class UIElement: self.renderable.render(brightness=0.1) self.renderable.x, self.renderable.y = orig_x, orig_y self.renderable.render() - + def destroy(self): for r in self.renderables: r.destroy() @@ -258,27 +275,25 @@ class UIArt(Art): class UIRenderable(TileRenderable): - grain_strength = 0.2 - + def get_projection_matrix(self): # don't use projection matrix, ie identity[0][0]=aspect; # rather do all aspect correction in UI.set_scale when determining quad size return self.ui.view_matrix - + def get_view_matrix(self): return self.ui.view_matrix class FPSCounterUI(UIElement): - tile_y = 1 tile_width, tile_height = 12, 2 snap_right = True game_mode_visible = True all_modes_visible = True visible = False - + def update(self): bg = 0 self.art.clear_frame_layer(0, 0, bg) @@ -288,13 +303,13 @@ class FPSCounterUI(UIElement): color = self.ui.colors.yellow if self.ui.app.fps < 10: color = self.ui.colors.red - text = '%.1f fps' % self.ui.app.fps + text = "%.1f fps" % self.ui.app.fps x = self.tile_width - 1 self.art.write_string(0, 0, x, 0, text, color, None, True) # display last tick time; frame_time includes delay, is useless - text = '%.1f ms ' % self.ui.app.frame_time + text = "%.1f ms " % self.ui.app.frame_time self.art.write_string(0, 0, x, 1, text, color, None, True) - + def render(self): # always show FPS if low if self.visible or self.ui.app.fps < 30: @@ -302,9 +317,8 @@ class FPSCounterUI(UIElement): class MessageLineUI(UIElement): - "when console outputs something new, show last line here before fading out" - + tile_y = 2 snap_left = True # just info, don't bother with hover, click etc @@ -314,21 +328,21 @@ class MessageLineUI(UIElement): game_mode_visible = True all_modes_visible = True drop_shadow = True - + def __init__(self, ui): UIElement.__init__(self, ui) # line we're currently displaying (even after fading out) - self.line = '' + self.line = "" self.last_post = self.ui.app.get_elapsed_time() self.hold_time = self.default_hold_time self.alpha = 1 - + def reset_art(self): self.tile_width = ceil(self.ui.width_tiles) self.art.resize(self.tile_width, self.tile_height) self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white) UIElement.reset_loc(self) - + def post_line(self, new_line, hold_time=None, error=False): "write a line to this element (ie so as not to spam console log)" self.hold_time = hold_time or self.default_hold_time @@ -336,12 +350,12 @@ class MessageLineUI(UIElement): color = self.ui.error_color_index if error else self.ui.colors.white start_x = 1 # trim to screen width - self.line = str(new_line)[:self.tile_width-start_x-1] + self.line = str(new_line)[: self.tile_width - start_x - 1] self.art.clear_frame_layer(0, 0, 0, color) self.art.write_string(0, 0, start_x, 0, self.line) self.alpha = 1 self.last_post = self.ui.app.get_elapsed_time() - + def update(self): if self.ui.app.get_elapsed_time() > self.last_post + (self.hold_time * 1000): if self.alpha >= self.fade_rate: @@ -349,70 +363,71 @@ class MessageLineUI(UIElement): if self.alpha <= self.fade_rate: self.alpha = 0 self.renderable.alpha = self.alpha - + def render(self): # TODO: draw if popup is visible but not obscuring message line? - if not self.ui.popup in self.ui.hovered_elements and not self.ui.console.visible: + if ( + self.ui.popup not in self.ui.hovered_elements + and not self.ui.console.visible + ): UIElement.render(self) class DebugTextUI(UIElement): - "simple UI element for posting debug text" - + tile_x, tile_y = 1, 4 tile_height = 20 clear_lines_after_render = True game_mode_visible = True visible = False - + def __init__(self, ui): UIElement.__init__(self, ui) self.lines = [] - + def reset_art(self): self.tile_width = ceil(self.ui.width_tiles) self.art.resize(self.tile_width, self.tile_height) self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white) UIElement.reset_loc(self) - + def post_lines(self, lines): if type(lines) is list: self.lines += lines else: self.lines += [lines] - + def update(self): self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white) - for y,line in enumerate(self.lines): + for y, line in enumerate(self.lines): self.art.write_string(0, 0, 0, y, line) - + def render(self): UIElement.render(self) if self.clear_lines_after_render: self.lines = [] - #self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white) + # self.art.clear_frame_layer(0, 0, 0, self.ui.colors.white) class ToolTip(UIElement): - "popup text label that is invoked and controlled by a UIButton hover" - + visible = False tile_width, tile_height = 30, 1 tile_x, tile_y = 10, 5 - + def set_text(self, text): - self.art.write_string(0, 0, 0, 0, text, - self.ui.colors.black, self.ui.colors.white) + self.art.write_string( + 0, 0, 0, 0, text, self.ui.colors.black, self.ui.colors.white + ) # clear tiles past end of text for x in range(len(text), self.tile_width): self.art.set_color_at(0, 0, x, 0, 0, 0) - + def reset_art(self): UIElement.reset_art(self) - self.art.clear_frame_layer(0, 0, - self.ui.colors.white, self.ui.colors.black) + self.art.clear_frame_layer(0, 0, self.ui.colors.white, self.ui.colors.black) class GameLabel(UIElement): @@ -423,9 +438,8 @@ class GameLabel(UIElement): class GameSelectionLabel(GameLabel): - - multi_select_label = '[%s selected]' - + multi_select_label = "[%s selected]" + def update(self): self.visible = False if self.ui.pulldown.visible or not self.ui.is_game_edit_ui_visible(): @@ -435,7 +449,7 @@ class GameSelectionLabel(GameLabel): self.visible = True if len(self.ui.app.gw.selected_objects) == 1: obj = self.ui.app.gw.selected_objects[0] - text = obj.name[:self.tile_width-1] + text = obj.name[: self.tile_width - 1] x, y, z = obj.x, obj.y, obj.z else: # draw "[N selected]" at avg of selected object locations @@ -453,10 +467,10 @@ class GameSelectionLabel(GameLabel): self.x, self.y = vector.world_to_screen_normalized(self.ui.app, x, y, z) self.reset_loc() + class GameHoverLabel(GameLabel): - alpha = 0.75 - + def update(self): self.visible = False if self.ui.pulldown.visible or not self.ui.is_game_edit_ui_visible(): @@ -465,7 +479,7 @@ class GameHoverLabel(GameLabel): return self.visible = True obj = self.ui.app.gw.hovered_focus_object - text = obj.name[:self.tile_width-1] + text = obj.name[: self.tile_width - 1] x, y, z = obj.x, obj.y, obj.z self.art.clear_line(0, 0, 0, self.ui.colors.white, -1) self.art.write_string(0, 0, 0, 0, text) diff --git a/ui_file_chooser_dialog.py b/ui_file_chooser_dialog.py index 83f4466..6840401 100644 --- a/ui_file_chooser_dialog.py +++ b/ui_file_chooser_dialog.py @@ -1,27 +1,33 @@ - -import os, time, json +import json +import os +import time from PIL import Image -from texture import Texture -from ui_chooser_dialog import ChooserDialog, ChooserItem, ChooserItemButton -from ui_console import OpenCommand, LoadCharSetCommand, LoadPaletteCommand -from ui_art_dialog import PaletteFromFileDialog, ImportOptionsDialog -from art import ART_DIR, ART_FILE_EXTENSION, THUMBNAIL_CACHE_DIR, SCRIPT_FILE_EXTENSION, ART_SCRIPT_DIR -from palette import Palette, PALETTE_DIR, PALETTE_EXTENSIONS -from charset import CharacterSet, CHARSET_DIR, CHARSET_FILE_EXTENSION +from art import ( + ART_DIR, + ART_FILE_EXTENSION, + ART_SCRIPT_DIR, + SCRIPT_FILE_EXTENSION, + THUMBNAIL_CACHE_DIR, +) +from charset import CHARSET_DIR, CHARSET_FILE_EXTENSION from image_export import write_thumbnail +from palette import PALETTE_DIR, PALETTE_EXTENSIONS +from texture import Texture +from ui_art_dialog import ImportOptionsDialog, PaletteFromFileDialog +from ui_chooser_dialog import ChooserDialog, ChooserItem +from ui_console import OpenCommand class BaseFileChooserItem(ChooserItem): - hide_file_extension = False - + def get_short_dir_name(self): # name should end in / but don't assume - dir_name = self.name[:-1] if self.name.endswith('/') else self.name - return os.path.basename(dir_name) + '/' - + dir_name = self.name[:-1] if self.name.endswith("/") else self.name + return os.path.basename(dir_name) + "/" + def get_label(self): if os.path.isdir(self.name): return self.get_short_dir_name() @@ -31,15 +37,15 @@ class BaseFileChooserItem(ChooserItem): return os.path.splitext(label)[0] else: return label - + def get_description_lines(self): if os.path.isdir(self.name): - if self.name == '..': - return ['[parent folder]'] + if self.name == "..": + return ["[parent folder]"] # TODO: # of items in dir? return [] return None - + def picked(self, element): # if this is different from the last clicked item, pick it if element.selected_item_index != self.index: @@ -52,8 +58,8 @@ class BaseFileChooserItem(ChooserItem): if not element.first_selection_made: element.first_selection_made = True return - if self.name == '..' and self.name != '/': - new_dir = os.path.abspath(os.path.abspath(element.current_dir) + '/..') + if self.name == ".." and self.name != "/": + new_dir = os.path.abspath(os.path.abspath(element.current_dir) + "/..") element.change_current_dir(new_dir) elif os.path.isdir(self.name): new_dir = element.current_dir + self.get_short_dir_name() @@ -62,39 +68,40 @@ class BaseFileChooserItem(ChooserItem): element.confirm_pressed() element.first_selection_made = False + class BaseFileChooserDialog(ChooserDialog): - "base class for choosers whose items correspond with files" + chooser_item_class = BaseFileChooserItem show_filenames = True file_extensions = [] - + def set_initial_dir(self): self.current_dir = self.ui.app.documents_dir self.field_texts[self.active_field] = self.current_dir - + def get_filenames(self): "subclasses override: get list of desired filenames" return self.get_sorted_dir_list() - + def get_sorted_dir_list(self): "common code for getting sorted directory + file lists" # list parent, then dirs, then filenames with extension(s) - parent = [] if self.current_dir == '/' else ['..'] + parent = [] if self.current_dir == "/" else [".."] if not os.path.exists(self.current_dir): return parent dirs, files = [], [] for filename in os.listdir(self.current_dir): # skip unix-hidden files - if filename.startswith('.'): + if filename.startswith("."): continue full_filename = self.current_dir + filename # if no extensions specified, take any file if len(self.file_extensions) == 0: - self.file_extensions = [''] + self.file_extensions = [""] for ext in self.file_extensions: if os.path.isdir(full_filename): - dirs += [full_filename + '/'] + dirs += [full_filename + "/"] break elif filename.lower().endswith(ext.lower()): files += [full_filename] @@ -102,7 +109,7 @@ class BaseFileChooserDialog(ChooserDialog): dirs.sort(key=lambda x: x.lower()) files.sort(key=lambda x: x.lower()) return parent + dirs + files - + def get_items(self): "populate and return items from list of files, loading as needed" items = [] @@ -123,12 +130,12 @@ class BaseFileChooserDialog(ChooserDialog): # art chooser # + class ArtChooserItem(BaseFileChooserItem): - # set in load() art_width = None hide_file_extension = True - + def get_description_lines(self): lines = BaseFileChooserItem.get_description_lines(self) if lines is not None: @@ -136,31 +143,33 @@ class ArtChooserItem(BaseFileChooserItem): if not self.art_width: return [] mod_time = time.gmtime(self.art_mod_time) - mod_time = time.strftime('%Y-%m-%d %H:%M:%S', mod_time) - lines = ['last change: %s' % mod_time] - line = '%s x %s, ' % (self.art_width, self.art_height) - line += '%s frame' % self.art_frames + mod_time = time.strftime("%Y-%m-%d %H:%M:%S", mod_time) + lines = ["last change: %s" % mod_time] + line = "%s x %s, " % (self.art_width, self.art_height) + line += "%s frame" % self.art_frames # pluralize properly - line += 's' if self.art_frames > 1 else '' - line += ', %s layer' % self.art_layers - line += 's' if self.art_layers > 1 else '' + line += "s" if self.art_frames > 1 else "" + line += ", %s layer" % self.art_layers + line += "s" if self.art_layers > 1 else "" lines += [line] - lines += ['char: %s, pal: %s' % (self.art_charset, self.art_palette)] + lines += ["char: %s, pal: %s" % (self.art_charset, self.art_palette)] return lines - + def get_preview_texture(self, app): if os.path.isdir(self.name): return - thumbnail_filename = app.cache_dir + THUMBNAIL_CACHE_DIR + self.art_hash + '.png' + thumbnail_filename = ( + app.cache_dir + THUMBNAIL_CACHE_DIR + self.art_hash + ".png" + ) # create thumbnail if it doesn't exist if not os.path.exists(thumbnail_filename): write_thumbnail(app, self.name, thumbnail_filename) # read thumbnail img = Image.open(thumbnail_filename) - img = img.convert('RGBA') + img = img.convert("RGBA") img = img.transpose(Image.FLIP_TOP_BOTTOM) return Texture(img.tobytes(), *img.size) - + def load(self, app): if os.path.isdir(self.name): return @@ -172,33 +181,36 @@ class ArtChooserItem(BaseFileChooserItem): self.art_hash = app.get_file_hash(self.name) # rather than load the entire art, just get some high level stats d = json.load(open(self.name)) - self.art_width, self.art_height = d['width'], d['height'] - self.art_frames = len(d['frames']) - self.art_layers = len(d['frames'][0]['layers']) - self.art_charset = d['charset'] - self.art_palette = d['palette'] + self.art_width, self.art_height = d["width"], d["height"] + self.art_frames = len(d["frames"]) + self.art_layers = len(d["frames"][0]["layers"]) + self.art_charset = d["charset"] + self.art_palette = d["palette"] class ArtChooserDialog(BaseFileChooserDialog): - - title = 'Open art' - confirm_caption = 'Open' - cancel_caption = 'Cancel' + title = "Open art" + confirm_caption = "Open" + cancel_caption = "Cancel" chooser_item_class = ArtChooserItem flip_preview_y = False directory_aware = True file_extensions = [ART_FILE_EXTENSION] - + def set_initial_dir(self): # TODO: IF no art in Documents dir yet, start in app/art/ for examples? # get last opened dir, else start in docs/game art dir if self.ui.app.last_art_dir: self.current_dir = self.ui.app.last_art_dir else: - self.current_dir = self.ui.app.gw.game_dir if self.ui.app.gw.game_dir else self.ui.app.documents_dir + self.current_dir = ( + self.ui.app.gw.game_dir + if self.ui.app.gw.game_dir + else self.ui.app.documents_dir + ) self.current_dir += ART_DIR self.field_texts[self.active_field] = self.current_dir - + def confirm_pressed(self): if not os.path.exists(self.field_texts[0]): return @@ -211,27 +223,26 @@ class ArtChooserDialog(BaseFileChooserDialog): # generic file chooser for importers # class GenericImportChooserDialog(BaseFileChooserDialog): - - title = 'Import %s' - confirm_caption = 'Import' - cancel_caption = 'Cancel' - # allowed extensions set by invoking + title = "Import %s" + confirm_caption = "Import" + cancel_caption = "Cancel" + # allowed extensions set by invoking file_extensions = [] show_preview_image = False directory_aware = True - + def __init__(self, ui, options): self.title %= ui.app.importer.format_name self.file_extensions = ui.app.importer.allowed_file_extensions BaseFileChooserDialog.__init__(self, ui, options) - + def set_initial_dir(self): if self.ui.app.last_import_dir: self.current_dir = self.ui.app.last_import_dir else: self.current_dir = self.ui.app.documents_dir self.field_texts[self.active_field] = self.current_dir - + def confirm_pressed(self): filename = self.field_texts[0] if not os.path.exists(filename): @@ -240,15 +251,13 @@ class GenericImportChooserDialog(BaseFileChooserDialog): self.dismiss() # importer might offer a dialog for options if self.ui.app.importer.options_dialog_class: - options = {'filename': filename} - self.ui.open_dialog(self.ui.app.importer.options_dialog_class, - options) + options = {"filename": filename} + self.ui.open_dialog(self.ui.app.importer.options_dialog_class, options) else: ImportOptionsDialog.do_import(self.ui.app, filename, {}) class ImageChooserItem(BaseFileChooserItem): - def get_preview_texture(self, app): if os.path.isdir(self.name): return @@ -258,26 +267,26 @@ class ImageChooserItem(BaseFileChooserItem): except: return try: - img = img.convert('RGBA') + img = img.convert("RGBA") except: # (probably) PIL bug: some images just crash! return None return img = img.transpose(Image.FLIP_TOP_BOTTOM) return Texture(img.tobytes(), *img.size) + class ImageFileChooserDialog(BaseFileChooserDialog): - - cancel_caption = 'Cancel' + cancel_caption = "Cancel" chooser_item_class = ImageChooserItem flip_preview_y = False directory_aware = True - file_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'gif'] + file_extensions = ["png", "jpg", "jpeg", "bmp", "gif"] + class PaletteFromImageChooserDialog(ImageFileChooserDialog): - - title = 'Palette from image' - confirm_caption = 'Choose' - + title = "Palette from image" + confirm_caption = "Choose" + def confirm_pressed(self): if not os.path.exists(self.field_texts[0]): return @@ -291,31 +300,31 @@ class PaletteFromImageChooserDialog(ImageFileChooserDialog): palette_filename = os.path.splitext(palette_filename)[0] self.ui.active_dialog.field_texts[1] = palette_filename + # # palette chooser # + class PaletteChooserItem(BaseFileChooserItem): - def get_label(self): return os.path.splitext(self.name)[0] - + def get_description_lines(self): colors = len(self.palette.colors) - return ['Unique colors: %s' % str(colors - 1)] - + return ["Unique colors: %s" % str(colors - 1)] + def get_preview_texture(self, app): return self.palette.src_texture - + def load(self, app): self.palette = app.load_palette(self.name) class PaletteChooserDialog(BaseFileChooserDialog): - - title = 'Choose palette' + title = "Choose palette" chooser_item_class = PaletteChooserItem - + def get_initial_selection(self): if not self.ui.active_art: return 0 @@ -324,9 +333,9 @@ class PaletteChooserDialog(BaseFileChooserDialog): # eg filename minus extension if item.label == self.ui.active_art.palette.name: return item.index - #print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__) + # print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__) return 0 - + def get_filenames(self): filenames = [] # search all files in dirs with appropriate extensions @@ -337,54 +346,54 @@ class PaletteChooserDialog(BaseFileChooserDialog): filenames.append(filename) filenames.sort(key=lambda x: x.lower()) return filenames - + def confirm_pressed(self): item = self.get_selected_item() self.ui.active_art.set_palette(item.palette, log=True) self.ui.popup.set_active_palette(item.palette) + # # charset chooser # + class CharsetChooserItem(BaseFileChooserItem): - def get_label(self): return os.path.splitext(self.name)[0] - + def get_description_lines(self): # first comment in file = description lines = [] - for line in open(self.charset.filename, encoding='utf-8').readlines(): + for line in open(self.charset.filename, encoding="utf-8").readlines(): line = line.strip() - if line.startswith('//'): + if line.startswith("//"): lines.append(line[2:]) break - lines.append('Characters: %s' % str(self.charset.last_index)) + lines.append("Characters: %s" % str(self.charset.last_index)) return lines - + def get_preview_texture(self, app): return self.charset.texture - + def load(self, app): self.charset = app.load_charset(self.name) class CharSetChooserDialog(BaseFileChooserDialog): - - title = 'Choose character set' + title = "Choose character set" flip_preview_y = False chooser_item_class = CharsetChooserItem - + def get_initial_selection(self): if not self.ui.active_art: return 0 for item in self.items: if item.label == self.ui.active_art.charset.name: return item.index - #print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__) + # print("couldn't find initial selection for %s, returning 0" % self.__class__.__name__) return 0 - + def get_filenames(self): filenames = [] # search all files in dirs with appropriate extensions @@ -394,7 +403,7 @@ class CharSetChooserDialog(BaseFileChooserDialog): filenames.append(filename) filenames.sort(key=lambda x: x.lower()) return filenames - + def confirm_pressed(self): item = self.get_selected_item() self.ui.active_art.set_charset(item.charset, log=True) @@ -405,11 +414,10 @@ class CharSetChooserDialog(BaseFileChooserDialog): class ArtScriptChooserItem(BaseFileChooserItem): - def get_label(self): label = os.path.splitext(self.name)[0] return os.path.basename(label) - + def get_description_lines(self): lines = [] # read every comment line until a non-comment line is encountered @@ -417,25 +425,24 @@ class ArtScriptChooserItem(BaseFileChooserItem): line = line.strip() if not line: continue - if not line.startswith('#'): + if not line.startswith("#"): break # snip # - line = line[line.index('#')+1:] + line = line[line.index("#") + 1 :] lines.append(line) return lines - + def load(self, app): self.script = open(self.name) class RunArtScriptDialog(BaseFileChooserDialog): - - title = 'Run Artscript' + title = "Run Artscript" tile_width, big_width = 70, 90 tile_height, big_height = 15, 25 chooser_item_class = ArtScriptChooserItem show_preview_image = False - + def get_filenames(self): filenames = [] # search all files in dirs with appropriate extensions @@ -445,7 +452,7 @@ class RunArtScriptDialog(BaseFileChooserDialog): filenames.append(dirname + filename) filenames.sort(key=lambda x: x.lower()) return filenames - + def confirm_pressed(self): item = self.get_selected_item() self.ui.app.last_art_script = item.name @@ -454,10 +461,9 @@ class RunArtScriptDialog(BaseFileChooserDialog): class OverlayImageFileChooserDialog(ImageFileChooserDialog): - - title = 'Choose overlay image' - confirm_caption = 'Choose' - + title = "Choose overlay image" + confirm_caption = "Choose" + def confirm_pressed(self): filename = self.field_texts[0] self.ui.app.set_overlay_image(filename) diff --git a/ui_game_dialog.py b/ui_game_dialog.py index 1c17827..2afd3a3 100644 --- a/ui_game_dialog.py +++ b/ui_game_dialog.py @@ -1,144 +1,146 @@ - -from ui_dialog import UIDialog, Field - -from ui_console import SetGameDirCommand, LoadGameStateCommand, SaveGameStateCommand -from ui_list_operations import LO_NONE, LO_SELECT_OBJECTS, LO_SET_SPAWN_CLASS, LO_LOAD_STATE, LO_SET_ROOM, LO_SET_ROOM_OBJECTS, LO_SET_OBJECT_ROOMS, LO_OPEN_GAME_DIR, LO_SET_ROOM_EDGE_WARP +from ui_console import LoadGameStateCommand, SaveGameStateCommand +from ui_dialog import Field, UIDialog +from ui_list_operations import ( + LO_NONE, +) class NewGameDirDialog(UIDialog): - title = 'New game' - field0_label = 'Name of new game folder:' - field1_label = 'Name of new game:' + title = "New game" + field0_label = "Name of new game folder:" + field1_label = "Name of new game:" field_width = UIDialog.default_field_width fields = [ Field(label=field0_label, type=str, width=field_width, oneline=False), - Field(label=field1_label, type=str, width=field_width, oneline=False) + Field(label=field1_label, type=str, width=field_width, oneline=False), ] - confirm_caption = 'Create' + confirm_caption = "Create" game_mode_visible = True - + # TODO: only allow names that don't already exist - + def get_initial_field_text(self, field_number): # provide a reasonable non-blank name if field_number == 0: - return 'newgame' + return "newgame" elif field_number == 1: return type(self.ui.app.gw).game_title - + def confirm_pressed(self): if self.ui.app.gw.create_new_game(self.field_texts[0], self.field_texts[1]): self.ui.app.enter_game_mode() self.dismiss() + class LoadGameStateDialog(UIDialog): - - title = 'Open game state' - field_label = 'Game state file to open:' + title = "Open game state" + field_label = "Game state file to open:" field_width = UIDialog.default_field_width - fields = [ - Field(label=field_label, type=str, width=field_width, oneline=False) - ] - confirm_caption = 'Open' + fields = [Field(label=field_label, type=str, width=field_width, oneline=False)] + confirm_caption = "Open" game_mode_visible = True - + # TODO: only allow valid game state file in current game directory - + def confirm_pressed(self): LoadGameStateCommand.execute(self.ui.console, [self.field_texts[0]]) self.dismiss() + class SaveGameStateDialog(UIDialog): - - title = 'Save game state' - field_label = 'New filename for game state:' + title = "Save game state" + field_label = "New filename for game state:" field_width = UIDialog.default_field_width - fields = [ - Field(label=field_label, type=str, width=field_width, oneline=False) - ] - confirm_caption = 'Save' + fields = [Field(label=field_label, type=str, width=field_width, oneline=False)] + confirm_caption = "Save" game_mode_visible = True - + def confirm_pressed(self): SaveGameStateCommand.execute(self.ui.console, [self.field_texts[0]]) self.dismiss() + class AddRoomDialog(UIDialog): - title = 'Add new room' - field0_label = 'Name for new room:' - field1_label = 'Class of new room:' + title = "Add new room" + field0_label = "Name for new room:" + field1_label = "Class of new room:" field_width = UIDialog.default_field_width fields = [ Field(label=field0_label, type=str, width=field_width, oneline=False), - Field(label=field1_label, type=str, width=field_width, oneline=False) + Field(label=field1_label, type=str, width=field_width, oneline=False), ] - confirm_caption = 'Add' + confirm_caption = "Add" game_mode_visible = True - invalid_room_name_error = 'Invalid room name.' - + invalid_room_name_error = "Invalid room name." + def get_initial_field_text(self, field_number): # provide a reasonable non-blank name if field_number == 0: - return 'Room ' + str(len(self.ui.app.gw.rooms) + 1) + return "Room " + str(len(self.ui.app.gw.rooms) + 1) elif field_number == 1: - return 'GameRoom' - + return "GameRoom" + def is_input_valid(self): - return self.field_texts[0] != '', self.invalid_room_name_error - + return self.field_texts[0] != "", self.invalid_room_name_error + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return self.ui.app.gw.add_room(self.field_texts[0], self.field_texts[1]) self.dismiss() + class SetRoomCamDialog(UIDialog): - title = 'Set room camera marker' + title = "Set room camera marker" tile_width = 48 - field0_label = 'Name of location marker object for this room:' + field0_label = "Name of location marker object for this room:" field_width = UIDialog.default_field_width - fields = [ - Field(label=field0_label, type=str, width=field_width, oneline=False) - ] - confirm_caption = 'Set' + fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)] + confirm_caption = "Set" game_mode_visible = True - + def dismiss(self): self.ui.edit_list_panel.set_list_operation(LO_NONE) UIDialog.dismiss(self) - + def confirm_pressed(self): self.ui.app.gw.current_room.set_camera_marker_name(self.field_texts[0]) self.dismiss() + class SetRoomEdgeWarpsDialog(UIDialog): - title = 'Set room edge warps' + title = "Set room edge warps" tile_width = 48 fields = 4 - field0_label = 'Name of room/object to warp at LEFT edge:' - field1_label = 'Name of room/object to warp at RIGHT edge:' - field2_label = 'Name of room/object to warp at TOP edge:' - field3_label = 'Name of room/object to warp at BOTTOM edge:' + field0_label = "Name of room/object to warp at LEFT edge:" + field1_label = "Name of room/object to warp at RIGHT edge:" + field2_label = "Name of room/object to warp at TOP edge:" + field3_label = "Name of room/object to warp at BOTTOM edge:" field_width = UIDialog.default_field_width fields = [ Field(label=field0_label, type=str, width=field_width, oneline=False), Field(label=field1_label, type=str, width=field_width, oneline=False), Field(label=field2_label, type=str, width=field_width, oneline=False), - Field(label=field3_label, type=str, width=field_width, oneline=False) + Field(label=field3_label, type=str, width=field_width, oneline=False), ] - confirm_caption = 'Set' + confirm_caption = "Set" game_mode_visible = True - + def get_initial_field_text(self, field_number): room = self.ui.app.gw.current_room - names = {0: room.left_edge_warp_dest_name, 1: room.right_edge_warp_dest_name, - 2: room.top_edge_warp_dest_name, 3: room.bottom_edge_warp_dest_name} + names = { + 0: room.left_edge_warp_dest_name, + 1: room.right_edge_warp_dest_name, + 2: room.top_edge_warp_dest_name, + 3: room.bottom_edge_warp_dest_name, + } return names[field_number] - + def dismiss(self): self.ui.edit_list_panel.set_list_operation(LO_NONE) UIDialog.dismiss(self) - + def confirm_pressed(self): room = self.ui.app.gw.current_room room.left_edge_warp_dest_name = self.field_texts[0] @@ -148,51 +150,50 @@ class SetRoomEdgeWarpsDialog(UIDialog): room.reset_edge_warps() self.dismiss() + class SetRoomBoundsObjDialog(UIDialog): - title = 'Set room edge object' - field0_label = 'Name of object to use for room bounds:' + title = "Set room edge object" + field0_label = "Name of object to use for room bounds:" field_width = UIDialog.default_field_width - fields = [ - Field(label=field0_label, type=str, width=field_width, oneline=False) - ] - confirm_caption = 'Set' + fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)] + confirm_caption = "Set" game_mode_visible = True - + def get_initial_field_text(self, field_number): if field_number == 0: return self.ui.app.gw.current_room.warp_edge_bounds_obj_name - + def dismiss(self): self.ui.edit_list_panel.set_list_operation(LO_NONE) UIDialog.dismiss(self) - + def confirm_pressed(self): room = self.ui.app.gw.current_room room.warp_edge_bounds_obj_name = self.field_texts[0] room.reset_edge_warps() self.dismiss() + class RenameRoomDialog(UIDialog): - title = 'Rename room' - field0_label = 'New name for current room:' + title = "Rename room" + field0_label = "New name for current room:" field_width = UIDialog.default_field_width - fields = [ - Field(label=field0_label, type=str, width=field_width, oneline=False) - ] - confirm_caption = 'Rename' + fields = [Field(label=field0_label, type=str, width=field_width, oneline=False)] + confirm_caption = "Rename" game_mode_visible = True - invalid_room_name_error = 'Invalid room name.' - + invalid_room_name_error = "Invalid room name." + def get_initial_field_text(self, field_number): if field_number == 0: return self.ui.app.gw.current_room.name - + def is_input_valid(self): - return self.field_texts[0] != '', self.invalid_room_name_error - + return self.field_texts[0] != "", self.invalid_room_name_error + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return world = self.ui.app.gw world.rename_room(world.current_room, self.field_texts[0]) self.dismiss() diff --git a/ui_game_menu_pulldown_item.py b/ui_game_menu_pulldown_item.py index deaeac7..553f60c 100644 --- a/ui_game_menu_pulldown_item.py +++ b/ui_game_menu_pulldown_item.py @@ -1,6 +1,14 @@ -# coding=utf-8 +from ui_menu_pulldown_item import ( + FileQuitItem, + PulldownMenuData, + PulldownMenuItem, + SeparatorItem, + ViewSetZoomItem, + ViewToggleCameraTiltItem, + ViewToggleCRTItem, + ViewToggleGridItem, +) -from ui_menu_pulldown_item import PulldownMenuItem, SeparatorItem, PulldownMenuData, FileQuitItem, ViewToggleCRTItem, ViewToggleCameraTiltItem, ViewSetZoomItem, ViewToggleGridItem class GameModePulldownMenuItem(PulldownMenuItem): # unless overridden, game mode items not allowed in art mode @@ -11,300 +19,408 @@ class GameModePulldownMenuItem(PulldownMenuItem): # game menu # class HideEditUIItem(GameModePulldownMenuItem): - label = 'Hide edit UI' - command = 'toggle_game_edit_ui' + label = "Hide edit UI" + command = "toggle_game_edit_ui" close_on_select = True always_active = True + class NewGameDirItem(GameModePulldownMenuItem): - label = 'New game…' - command = 'new_game_dir' + label = "New game…" + command = "new_game_dir" always_active = True + class SetGameDirItem(GameModePulldownMenuItem): - label = 'Open game…' - command = 'set_game_dir' + label = "Open game…" + command = "set_game_dir" close_on_select = True always_active = True + class PauseGameItem(GameModePulldownMenuItem): - label = 'blah' - command = 'toggle_anim_playback' + label = "blah" + command = "toggle_anim_playback" always_active = True + def get_label(app): - return ['Pause game', 'Unpause game'][app.gw.paused] + return ["Pause game", "Unpause game"][app.gw.paused] + class OpenConsoleItem(GameModePulldownMenuItem): - label = 'Open dev console' - command = 'toggle_console' + label = "Open dev console" + command = "toggle_console" close_on_select = True always_active = True art_mode_allowed = True + # # state menu # class ResetStateItem(GameModePulldownMenuItem): - label = 'Reset to last state' - command = 'reset_game' + label = "Reset to last state" + command = "reset_game" close_on_select = True + def should_dim(app): return not app.gw.game_dir + class LoadStateItem(GameModePulldownMenuItem): - label = 'Load state…' - command = 'load_game_state' + label = "Load state…" + command = "load_game_state" close_on_select = True + def should_dim(app): return not app.gw.game_dir + class SaveStateItem(GameModePulldownMenuItem): - label = 'Save current state' - command = 'save_current' + label = "Save current state" + command = "save_current" close_on_select = True + def should_dim(app): return not app.gw.game_dir + class SaveNewStateItem(GameModePulldownMenuItem): - label = 'Save new state…' - command = 'save_game_state' + label = "Save new state…" + command = "save_game_state" + def should_dim(app): return not app.gw.game_dir + # # view menu # class ObjectsToCameraItem(GameModePulldownMenuItem): - label = 'Move selected object(s) to camera' - command = 'objects_to_camera' + label = "Move selected object(s) to camera" + command = "objects_to_camera" close_on_select = True + def should_dim(app): return len(app.gw.selected_objects) == 0 + class CameraToObjectsItem(GameModePulldownMenuItem): - label = 'Move camera to selected object' - command = 'camera_to_objects' + label = "Move camera to selected object" + command = "camera_to_objects" close_on_select = True + def should_dim(app): return len(app.gw.selected_objects) != 1 + class ToggleDebugObjectsItem(GameModePulldownMenuItem): - label = ' Draw debug objects' - command = 'toggle_debug_objects' + label = " Draw debug objects" + command = "toggle_debug_objects" + def should_dim(app): return not app.gw.game_dir + def should_mark(ui): return ui.app.gw.properties and ui.app.gw.properties.draw_debug_objects + class ToggleOriginVizItem(GameModePulldownMenuItem): - label = ' Show all object origins' - command = 'toggle_all_origin_viz' + label = " Show all object origins" + command = "toggle_all_origin_viz" + def should_dim(app): return not app.gw.game_dir + def should_mark(ui): return ui.app.gw.show_origin_all + class ToggleBoundsVizItem(GameModePulldownMenuItem): - label = ' Show all object bounds' - command = 'toggle_all_bounds_viz' + label = " Show all object bounds" + command = "toggle_all_bounds_viz" + def should_dim(app): return not app.gw.game_dir + def should_mark(ui): return ui.app.gw.show_bounds_all + class ToggleCollisionVizItem(GameModePulldownMenuItem): - label = ' Show all object collision' - command = 'toggle_all_collision_viz' + label = " Show all object collision" + command = "toggle_all_collision_viz" + def should_dim(app): return not app.gw.game_dir + def should_mark(ui): return ui.app.gw.show_collision_all + # # world menu # class EditWorldPropertiesItem(GameModePulldownMenuItem): - label = 'Edit world properties…' - command = 'edit_world_properties' + label = "Edit world properties…" + command = "edit_world_properties" close_on_select = True + def should_dim(app): return not app.gw.game_dir + # # room menu # + class ChangeRoomItem(GameModePulldownMenuItem): - label = 'Change current room…' - command = 'change_current_room' + label = "Change current room…" + command = "change_current_room" close_on_select = True + def should_dim(app): return len(app.gw.rooms) == 0 + class AddRoomItem(GameModePulldownMenuItem): - label = 'Add room…' - command = 'add_room' + label = "Add room…" + command = "add_room" + def should_dim(app): return not app.gw.game_dir + class SetRoomObjectsItem(GameModePulldownMenuItem): - label = 'Add/remove objects from room…' - command = 'set_room_objects' + label = "Add/remove objects from room…" + command = "set_room_objects" close_on_select = True + def should_dim(app): return app.gw.current_room is None + class RemoveRoomItem(GameModePulldownMenuItem): - label = 'Remove this room' - command = 'remove_current_room' + label = "Remove this room" + command = "remove_current_room" close_on_select = True + def should_dim(app): return app.gw.current_room is None + class RenameRoomItem(GameModePulldownMenuItem): - label = 'Rename this room…' - command = 'rename_current_room' + label = "Rename this room…" + command = "rename_current_room" + def should_dim(app): return app.gw.current_room is None + class ToggleAllRoomsVizItem(GameModePulldownMenuItem): - label = 'blah' - command = 'toggle_all_rooms_visible' + label = "blah" + command = "toggle_all_rooms_visible" + def should_dim(app): return len(app.gw.rooms) == 0 + def get_label(app): - return ['Show all rooms', 'Show only current room'][app.gw.show_all_rooms] + return ["Show all rooms", "Show only current room"][app.gw.show_all_rooms] + class ToggleListOnlyRoomObjectItem(GameModePulldownMenuItem): - label = ' List only objects in this room' - command = 'toggle_list_only_room_objects' + label = " List only objects in this room" + command = "toggle_list_only_room_objects" + def should_dim(app): return len(app.gw.rooms) == 0 + def should_mark(ui): return ui.app.gw.list_only_current_room_objects + class ToggleRoomCamerasItem(GameModePulldownMenuItem): - label = ' Camera changes with room' - command = 'toggle_room_camera_changes' + label = " Camera changes with room" + command = "toggle_room_camera_changes" + def should_dim(app): return len(app.gw.rooms) == 0 + def should_mark(ui): return ui.app.gw.room_camera_changes_enabled + class SetRoomCameraItem(GameModePulldownMenuItem): label = "Set this room's camera marker…" - command = 'set_room_camera_marker' + command = "set_room_camera_marker" + def should_dim(app): return app.gw.current_room is None + class SetRoomEdgeDestinationsItem(GameModePulldownMenuItem): label = "Set this room's edge warps…" - command = 'set_room_edge_warps' + command = "set_room_edge_warps" + def should_dim(app): return app.gw.current_room is None + class SetRoomBoundsObject(GameModePulldownMenuItem): label = "Set this room's edge object…" - command = 'set_room_bounds_obj' + command = "set_room_bounds_obj" + def should_dim(app): return app.gw.current_room is None + class AddSelectedToCurrentRoomItem(GameModePulldownMenuItem): - label = 'Add selected objects to this room' - command = 'add_selected_to_room' + label = "Add selected objects to this room" + command = "add_selected_to_room" + def should_dim(app): return app.gw.current_room is None or len(app.gw.selected_objects) == 0 + class RemoveSelectedFromCurrentRoomItem(GameModePulldownMenuItem): - label = 'Remove selected objects from this room' - command = 'remove_selected_from_room' + label = "Remove selected objects from this room" + command = "remove_selected_from_room" + def should_dim(app): return app.gw.current_room is None or len(app.gw.selected_objects) == 0 + # # object menu # + class SpawnObjectItem(GameModePulldownMenuItem): - label = 'Spawn object…' - command = 'choose_spawn_object_class' + label = "Spawn object…" + command = "choose_spawn_object_class" close_on_select = True + def should_dim(app): return not app.gw.game_dir + class DuplicateObjectsItem(GameModePulldownMenuItem): - label = 'Duplicate selected objects' - command = 'duplicate_selected_objects' + label = "Duplicate selected objects" + command = "duplicate_selected_objects" close_on_select = True + def should_dim(app): return len(app.gw.selected_objects) == 0 + class SelectObjectsItem(GameModePulldownMenuItem): - label = 'Select objects…' - command = 'select_objects' + label = "Select objects…" + command = "select_objects" close_on_select = True + def should_dim(app): return not app.gw.game_dir + class EditArtForObjectsItem(GameModePulldownMenuItem): - label = 'Edit art for selected…' - command = 'edit_art_for_selected_objects' + label = "Edit art for selected…" + command = "edit_art_for_selected_objects" close_on_select = True + def should_dim(app): return len(app.gw.selected_objects) == 0 + class SetObjectRoomsItem(GameModePulldownMenuItem): - label = 'Add/remove this object from rooms…' - command = 'set_object_rooms' + label = "Add/remove this object from rooms…" + command = "set_object_rooms" close_on_select = True + def should_dim(app): return len(app.gw.selected_objects) != 1 + class DeleteSelectedObjectsItem(GameModePulldownMenuItem): - label = 'Delete selected object(s)' - command = 'erase_selection_or_art' + label = "Delete selected object(s)" + command = "erase_selection_or_art" close_on_select = True + def should_dim(app): return len(app.gw.selected_objects) == 0 + class GameMenuData(PulldownMenuData): - items = [HideEditUIItem, OpenConsoleItem, SeparatorItem, - NewGameDirItem, SetGameDirItem, PauseGameItem, SeparatorItem, - FileQuitItem] + items = [ + HideEditUIItem, + OpenConsoleItem, + SeparatorItem, + NewGameDirItem, + SetGameDirItem, + PauseGameItem, + SeparatorItem, + FileQuitItem, + ] + class GameStateMenuData(PulldownMenuData): items = [ResetStateItem, LoadStateItem, SaveStateItem, SaveNewStateItem] + class GameViewMenuData(PulldownMenuData): - items = [ViewToggleCRTItem, ViewToggleGridItem, SeparatorItem, - ViewSetZoomItem, ViewToggleCameraTiltItem, SeparatorItem, - ObjectsToCameraItem, CameraToObjectsItem, ToggleDebugObjectsItem, - ToggleOriginVizItem, ToggleBoundsVizItem, ToggleCollisionVizItem] - + items = [ + ViewToggleCRTItem, + ViewToggleGridItem, + SeparatorItem, + ViewSetZoomItem, + ViewToggleCameraTiltItem, + SeparatorItem, + ObjectsToCameraItem, + CameraToObjectsItem, + ToggleDebugObjectsItem, + ToggleOriginVizItem, + ToggleBoundsVizItem, + ToggleCollisionVizItem, + ] + def should_mark_item(item, ui): - if hasattr(item, 'should_mark'): + if hasattr(item, "should_mark"): return item.should_mark(ui) return False + class GameWorldMenuData(PulldownMenuData): items = [EditWorldPropertiesItem] + class GameRoomMenuData(PulldownMenuData): - items = [ChangeRoomItem, AddRoomItem, RemoveRoomItem, RenameRoomItem, - ToggleAllRoomsVizItem, ToggleListOnlyRoomObjectItem, ToggleRoomCamerasItem, SeparatorItem, - AddSelectedToCurrentRoomItem, RemoveSelectedFromCurrentRoomItem, - SetRoomObjectsItem, SeparatorItem, - SetRoomCameraItem, SetRoomEdgeDestinationsItem, SetRoomBoundsObject, - SeparatorItem + items = [ + ChangeRoomItem, + AddRoomItem, + RemoveRoomItem, + RenameRoomItem, + ToggleAllRoomsVizItem, + ToggleListOnlyRoomObjectItem, + ToggleRoomCamerasItem, + SeparatorItem, + AddSelectedToCurrentRoomItem, + RemoveSelectedFromCurrentRoomItem, + SetRoomObjectsItem, + SeparatorItem, + SetRoomCameraItem, + SetRoomEdgeDestinationsItem, + SetRoomBoundsObject, + SeparatorItem, ] + def should_mark_item(item, ui): "show checkmark for current room" if not ui.app.gw.current_room: return False - if hasattr(item, 'should_mark'): + if hasattr(item, "should_mark"): return item.should_mark(ui) return ui.app.gw.current_room.name == item.cb_arg - + def get_items(app): items = [] if len(app.gw.rooms) == 0: @@ -320,18 +436,21 @@ class GameRoomMenuData(PulldownMenuData): if len(item.label) + 1 > longest_line: longest_line = len(item.label) + 1 # cap at max allowed line length - for room_name,room in app.gw.rooms.items(): - class TempMenuItemClass(GameModePulldownMenuItem): pass + for room_name, room in app.gw.rooms.items(): + + class TempMenuItemClass(GameModePulldownMenuItem): + pass + item = TempMenuItemClass # leave spaces for mark - item.label = ' %s' % room_name + item.label = " %s" % room_name # pad, put Z depth on far right item.label = item.label.ljust(longest_line) # trim to keep below a max length item.label = item.label[:longest_line] # tell PulldownMenu's button creation process not to auto-pad item.no_pad = True - item.command = 'change_current_room_to' + item.command = "change_current_room_to" item.cb_arg = room_name items.append(item) # sort room list alphabetically so it's stable, if arbitrary @@ -340,6 +459,12 @@ class GameRoomMenuData(PulldownMenuData): class GameObjectMenuData(PulldownMenuData): - items = [SpawnObjectItem, DuplicateObjectsItem, SeparatorItem, - SelectObjectsItem, EditArtForObjectsItem, SetObjectRoomsItem, - DeleteSelectedObjectsItem] + items = [ + SpawnObjectItem, + DuplicateObjectsItem, + SeparatorItem, + SelectObjectsItem, + EditArtForObjectsItem, + SetObjectRoomsItem, + DeleteSelectedObjectsItem, + ] diff --git a/ui_info_dialog.py b/ui_info_dialog.py index 2f09e58..85df1c5 100644 --- a/ui_info_dialog.py +++ b/ui_info_dialog.py @@ -1,65 +1,65 @@ import sdl2 -from ui_element import UIElement from ui_dialog import UIDialog +from ui_element import UIElement + class PagedInfoDialog(UIDialog): - "dialog that presents multiple pages of info w/ buttons to navigate next/last page" - - title = 'Info' + + title = "Info" # message = list of page strings, each can be triple-quoted / contain line breaks - message = [''] + message = [""] tile_width = 54 - confirm_caption = '>>' - other_caption = '<<' - cancel_caption = 'Done' + confirm_caption = ">>" + other_caption = "<<" + cancel_caption = "Done" other_button_visible = True extra_lines = 1 - + def __init__(self, ui, options): self.page = 0 UIDialog.__init__(self, ui, options) self.reset_art() - + def update(self): # disable prev/next buttons if we're at either end of the page list if self.page == 0: self.other_button.can_hover = False - self.other_button.set_state('dimmed') + self.other_button.set_state("dimmed") elif self.page == len(self.message) - 1: self.confirm_button.can_hover = False - self.confirm_button.set_state('dimmed') + self.confirm_button.set_state("dimmed") else: for button in [self.confirm_button, self.other_button]: button.can_hover = True button.dimmed = False - if button.state != 'normal': - button.set_state('normal') + if button.state != "normal": + button.set_state("normal") UIElement.update(self) - + def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed): keystr = sdl2.SDL_GetKeyName(key).decode() - if keystr == 'Left': + if keystr == "Left": self.other_pressed() - elif keystr == 'Right': + elif keystr == "Right": self.confirm_pressed() - elif keystr == 'Escape': + elif keystr == "Escape": self.cancel_pressed() - + def get_message(self): - return self.message[self.page].rstrip().split('\n') - + return self.message[self.page].rstrip().split("\n") + def confirm_pressed(self): # confirm repurposed to "next page" if self.page < len(self.message) - 1: self.page += 1 # redraw, tell reset_art not to resize self.reset_art(False) - + def cancel_pressed(self): self.dismiss() - + def other_pressed(self): # other repurposed to "previous page" if self.page > 0: @@ -68,8 +68,8 @@ class PagedInfoDialog(UIDialog): about_message = [ -# max line width 50 characters! -""" + # max line width 50 characters! + """ by JP LeBreton (c) 2014-2022 | Playscii was made with the support of many nice @@ -89,7 +89,7 @@ James Noble, David Pittman, Richard Porczak, Dan Sanderson, Shannon Strucci, Pablo López Soriano, Jack Turner, Chris Welch, Andrew Yoder """, -""" + """ Programming Contributions: Mattias Gustavsson, Rohit Nirmal, Sean Gubelman, @@ -108,7 +108,7 @@ Anna Anthropy, Andi McClure, Bret Victor, Tim Sweeney (ZZT), Craig Hickman (Kid Pix), Bill Atkinson (HyperCard) """, -""" + """ Love, Encouragement, Moral Support: L Stiger @@ -120,14 +120,16 @@ Aubrey Hesselgren Zak McClendon Claire Hosking #tool-design -""" +""", ] + class AboutDialog(PagedInfoDialog): - title = 'Playscii' + title = "Playscii" message = about_message game_mode_visible = True all_modes_visible = True + def __init__(self, ui, options): - self.title += ' %s' % ui.app.version + self.title += " %s" % ui.app.version PagedInfoDialog.__init__(self, ui, options) diff --git a/ui_list_operations.py b/ui_list_operations.py index 6fe5623..a0f31f8 100644 --- a/ui_list_operations.py +++ b/ui_list_operations.py @@ -1,4 +1,3 @@ - # list operations - tells ListPanel what to do when clicked LO_NONE = 0 diff --git a/ui_menu_bar.py b/ui_menu_bar.py index 5d74fbc..a4cf5c0 100644 --- a/ui_menu_bar.py +++ b/ui_menu_bar.py @@ -1,15 +1,33 @@ from math import ceil -from ui_element import UIElement -from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT -from ui_menu_pulldown_item import FileMenuData, EditMenuData, ToolMenuData, ViewMenuData, ArtMenuData, FrameMenuData, LayerMenuData, CharColorMenuData, HelpMenuData -from ui_game_menu_pulldown_item import GameMenuData, GameStateMenuData, GameViewMenuData, GameWorldMenuData, GameRoomMenuData, GameObjectMenuData -from ui_info_dialog import AboutDialog -from ui_colors import UIColors from renderable_sprite import UISpriteRenderable +from ui_button import TEXT_CENTER, UIButton +from ui_colors import UIColors +from ui_element import UIElement +from ui_game_menu_pulldown_item import ( + GameMenuData, + GameObjectMenuData, + GameRoomMenuData, + GameStateMenuData, + GameViewMenuData, + GameWorldMenuData, +) +from ui_info_dialog import AboutDialog +from ui_menu_pulldown_item import ( + ArtMenuData, + CharColorMenuData, + EditMenuData, + FileMenuData, + FrameMenuData, + HelpMenuData, + LayerMenuData, + ToolMenuData, + ViewMenuData, +) + class MenuButton(UIButton): - caption = 'Base Class Menu Button' + caption = "Base Class Menu Button" caption_justify = TEXT_CENTER # menu data is just a class w/ little more than a list of items, partly # so we don't have to list all the items here in a different module @@ -18,11 +36,11 @@ class MenuButton(UIButton): normal_bg_color = UIColors.white hovered_bg_color = UIColors.lightgrey dimmed_bg_color = UIColors.lightgrey - + def __init__(self, element): UIButton.__init__(self, element) self.callback = self.open_pulldown - + def open_pulldown(self): # don't open menus if a dialog is up if self.element.ui.active_dialog: @@ -44,118 +62,137 @@ class MenuButton(UIButton): # playscii logo button = normal UIButton, opens About screen directly class PlaysciiMenuButton(UIButton): - name = 'playscii' - caption = ' ' + name = "playscii" + caption = " " caption_justify = TEXT_CENTER width = len(caption) + 2 normal_bg_color = MenuButton.normal_bg_color hovered_bg_color = MenuButton.hovered_bg_color dimmed_bg_color = MenuButton.dimmed_bg_color + # # art mode menu buttons # + class FileMenuButton(MenuButton): - name = 'file' - caption = 'File' + name = "file" + caption = "File" menu_data = FileMenuData + class EditMenuButton(MenuButton): - name = 'edit' - caption = 'Edit' + name = "edit" + caption = "Edit" menu_data = EditMenuData + class ToolMenuButton(MenuButton): - name = 'tool' - caption = 'Tool' + name = "tool" + caption = "Tool" menu_data = ToolMenuData + class ViewMenuButton(MenuButton): - name = 'view' - caption = 'View' + name = "view" + caption = "View" menu_data = ViewMenuData + class ArtMenuButton(MenuButton): - name = 'art' - caption = 'Art' + name = "art" + caption = "Art" menu_data = ArtMenuData + class FrameMenuButton(MenuButton): - name = 'frame' - caption = 'Frame' + name = "frame" + caption = "Frame" menu_data = FrameMenuData + class LayerMenuButton(MenuButton): - name = 'layer' - caption = 'Layer' + name = "layer" + caption = "Layer" menu_data = LayerMenuData + class CharColorMenuButton(MenuButton): - name = 'char_color' - caption = 'Char/Color' + name = "char_color" + caption = "Char/Color" menu_data = CharColorMenuData + # (appears in both art and game mode menus) class HelpMenuButton(MenuButton): - name = 'help' - caption = 'Help' + name = "help" + caption = "Help" menu_data = HelpMenuData + # # game mode menu buttons # + class GameMenuButton(MenuButton): - name = 'game' - caption = 'Game' + name = "game" + caption = "Game" menu_data = GameMenuData + class StateMenuButton(MenuButton): - name = 'state' - caption = 'State' + name = "state" + caption = "State" menu_data = GameStateMenuData + class GameViewMenuButton(MenuButton): - name = 'view' - caption = 'View' + name = "view" + caption = "View" menu_data = GameViewMenuData + class WorldMenuButton(MenuButton): - name = 'world' - caption = 'World' + name = "world" + caption = "World" menu_data = GameWorldMenuData + class RoomMenuButton(MenuButton): - name = 'room' - caption = 'Room' + name = "room" + caption = "Room" menu_data = GameRoomMenuData + class ObjectMenuButton(MenuButton): - name = 'object' - caption = 'Object' + name = "object" + caption = "Object" menu_data = GameObjectMenuData + class ModeMenuButton(UIButton): caption_justify = TEXT_CENTER normal_bg_color = UIColors.black normal_fg_color = UIColors.white - #hovered_bg_color = UIColors.lightgrey - #dimmed_bg_color = UIColors.lightgrey + # hovered_bg_color = UIColors.lightgrey + # dimmed_bg_color = UIColors.lightgrey + class ArtModeMenuButton(ModeMenuButton): - caption = 'Game Mode' + caption = "Game Mode" width = len(caption) + 2 + class GameModeMenuButton(ModeMenuButton): - caption = 'Art Mode' + caption = "Art Mode" width = len(caption) + 2 class MenuBar(UIElement): - "main menu bar element, has lots of buttons which control the pulldown" - + snap_top = True snap_left = True always_consume_input = True @@ -165,7 +202,7 @@ class MenuBar(UIElement): mode_button_class = None # empty tiles between each button button_padding = 1 - + def __init__(self, ui): # bitmap icon for about menu button self.playscii_sprite = UISpriteRenderable(ui.app) @@ -180,7 +217,7 @@ class MenuBar(UIElement): button.width = len(button.caption) + 2 button.x = x x += button.width + self.button_padding - setattr(self, '%s_button' % button.name, button) + setattr(self, "%s_button" % button.name, button) # NOTE: callback already defined in MenuButton class, # menu data for pulldown with set in MenuButton subclass button.pulldown = self.ui.pulldown @@ -197,22 +234,24 @@ class MenuBar(UIElement): if not self.mode_button_class: return self.mode_button = self.mode_button_class(self) - self.mode_button.x = int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width + self.mode_button.x = ( + int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width + ) self.mode_button.callback = self.toggle_game_mode self.buttons.append(self.mode_button) - + def reset_icon(self): inv_aspect = self.ui.app.window_height / self.ui.app.window_width self.playscii_sprite.scale_x = self.art.quad_height * inv_aspect self.playscii_sprite.scale_y = self.art.quad_height self.playscii_sprite.x = -1 + self.art.quad_width self.playscii_sprite.y = 1 - self.art.quad_height - + def open_about(self): if self.ui.active_dialog: return self.ui.open_dialog(AboutDialog) - + def toggle_game_mode(self): if self.ui.active_dialog: return @@ -221,18 +260,18 @@ class MenuBar(UIElement): else: self.ui.app.exit_game_mode() self.ui.app.update_window_title() - + def close_active_menu(self): # un-dim active menu button for button in self.menu_buttons: if button.name == self.active_menu_name: button.dimmed = False - button.set_state('normal') + button.set_state("normal") self.active_menu_name = None self.ui.pulldown.visible = False self.ui.keyboard_focus_element = None self.ui.refocus_keyboard() - + def refresh_active_menu(self): if not self.ui.pulldown.visible: return @@ -240,36 +279,36 @@ class MenuBar(UIElement): if button.name == self.active_menu_name: # don't reset keyboard nav index self.ui.pulldown.open_at(button, False) - + def open_menu_by_name(self, menu_name): if not self.ui.app.can_edit: return for button in self.menu_buttons: if button.name == menu_name: button.callback() - + def open_menu_by_index(self, index): if index > len(self.menu_buttons) - 1: return # don't navigate to the about menu # (does this mean it's not accessible via kb-only? probably, that's fine) - if self.menu_buttons[index].name == 'playscii': + if self.menu_buttons[index].name == "playscii": return self.menu_buttons[index].callback() - + def get_menu_index(self, menu_name): - for i,button in enumerate(self.menu_buttons): + for i, button in enumerate(self.menu_buttons): if button.name == self.active_menu_name: return i - + def next_menu(self): i = self.get_menu_index(self.active_menu_name) self.open_menu_by_index(i + 1) - + def previous_menu(self): i = self.get_menu_index(self.active_menu_name) self.open_menu_by_index(i - 1) - + def reset_art(self): self.tile_width = ceil(self.ui.width_tiles * self.ui.scale) # must resize here, as window width will vary @@ -280,28 +319,46 @@ class MenuBar(UIElement): self.art.clear_frame_layer(0, 0, bg, fg) # reposition right-justified mode switch button if self.mode_button: - self.mode_button.x = int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width + self.mode_button.x = ( + int(self.ui.width_tiles * self.ui.scale) - self.mode_button.width + ) # draw buttons, etc UIElement.reset_art(self) self.reset_icon() - + def render(self): UIElement.render(self) self.playscii_sprite.render() - + def destroy(self): UIElement.destroy(self) self.playscii_sprite.destroy() + class ArtMenuBar(MenuBar): - button_classes = [FileMenuButton, EditMenuButton, ToolMenuButton, - ViewMenuButton, ArtMenuButton, FrameMenuButton, - LayerMenuButton, CharColorMenuButton, HelpMenuButton] + button_classes = [ + FileMenuButton, + EditMenuButton, + ToolMenuButton, + ViewMenuButton, + ArtMenuButton, + FrameMenuButton, + LayerMenuButton, + CharColorMenuButton, + HelpMenuButton, + ] mode_button_class = GameModeMenuButton + class GameMenuBar(MenuBar): - button_classes = [GameMenuButton, StateMenuButton, GameViewMenuButton, - WorldMenuButton, RoomMenuButton, ObjectMenuButton, - HelpMenuButton] + button_classes = [ + GameMenuButton, + StateMenuButton, + GameViewMenuButton, + WorldMenuButton, + RoomMenuButton, + ObjectMenuButton, + HelpMenuButton, + ] game_mode_visible = True mode_button_class = ArtModeMenuButton diff --git a/ui_menu_pulldown.py b/ui_menu_pulldown.py index b5aa4d5..98eb7da 100644 --- a/ui_menu_pulldown.py +++ b/ui_menu_pulldown.py @@ -1,24 +1,23 @@ - -from ui_element import UIElement +from art import UV_FLIPX, UV_FLIPY, UV_ROTATE180 from ui_button import UIButton from ui_colors import UIColors -from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY -from ui_menu_pulldown_item import PulldownMenuItem, PulldownMenuData, SeparatorItem +from ui_element import UIElement +from ui_menu_pulldown_item import PulldownMenuData, PulldownMenuItem, SeparatorItem class MenuItemButton(UIButton): dimmed_fg_color = UIColors.medgrey dimmed_bg_color = UIColors.lightgrey - + def hover(self): UIButton.hover(self) # keyboard nav if hovering with mouse - for i,button in enumerate(self.element.buttons): + for i, button in enumerate(self.element.buttons): if button is self: self.element.keyboard_nav_index = i self.element.update_keyboard_hover() break - + def click(self): UIButton.click(self) if self.item.close_on_select: @@ -26,9 +25,8 @@ class MenuItemButton(UIButton): class PulldownMenu(UIElement): - "element that's moved and resized based on currently active pulldown" - + label_shortcut_padding = 5 visible = False always_consume_input = True @@ -41,7 +39,7 @@ class PulldownMenu(UIElement): all_modes_visible = True support_keyboard_navigation = True keyboard_nav_left_right = True - + def open_at(self, menu_button, reset_keyboard_nav_index=True): # set X and Y based on calling menu button's location self.tile_x = menu_button.x @@ -57,7 +55,7 @@ class PulldownMenu(UIElement): if menu_button.menu_data.get_items is not PulldownMenuData.get_items: items += menu_button.menu_data.get_items(self.ui.app) for item in items: - shortcut,command = self.get_shortcut(item) + shortcut, command = self.get_shortcut(item) shortcuts.append(shortcut) callbacks.append(command) # get label, static or dynamic @@ -81,11 +79,18 @@ class PulldownMenu(UIElement): self.draw_border(menu_button) # create as many buttons as needed, set their sizes, captions, callbacks self.buttons = [] - for i,item in enumerate(items): + for i, item in enumerate(items): # skip button creation for separators, just draw a line if item is SeparatorItem: for x in range(1, self.tile_width - 1): - self.art.set_tile_at(0, 0, x, i+1, self.border_horizontal_line_char, self.border_color) + self.art.set_tile_at( + 0, + 0, + x, + i + 1, + self.border_horizontal_line_char, + self.border_color, + ) continue button = MenuItemButton(self) # give button a handle to its item @@ -99,30 +104,33 @@ class PulldownMenu(UIElement): button.caption = full_label button.width = len(full_label) button.x = 1 - button.y = i+1 + button.y = i + 1 button.callback = callbacks[i] if item.cb_arg is not None: button.cb_arg = item.cb_arg # dim items that aren't applicable to current app state if not item.always_active and item.should_dim(self.ui.app): - button.set_state('dimmed') + button.set_state("dimmed") button.can_hover = False self.buttons.append(button) # set our X and Y, draw buttons, etc self.reset_loc() self.reset_art() # if this menu has special logic for marking items, use it - if not menu_button.menu_data.should_mark_item is PulldownMenuData.should_mark_item: - for i,item in enumerate(items): + if ( + menu_button.menu_data.should_mark_item + is not PulldownMenuData.should_mark_item + ): + for i, item in enumerate(items): if menu_button.menu_data.should_mark_item(item, self.ui): - self.art.set_char_index_at(0, 0, 1, i+1, self.mark_char) + self.art.set_char_index_at(0, 0, 1, i + 1, self.mark_char) # reset keyboard nav state for popups if reset_keyboard_nav_index: self.keyboard_nav_index = 0 self.keyboard_navigate(0, 0) self.visible = True self.ui.keyboard_focus_element = self - + def draw_border(self, menu_button): "draws a fancy lil frame around the pulldown's edge" fg = self.border_color @@ -130,12 +138,12 @@ class PulldownMenu(UIElement): # top/bottom edges for x in range(self.tile_width): self.art.set_tile_at(0, 0, x, 0, char, fg) - self.art.set_tile_at(0, 0, x, self.tile_height-1, char, fg) + self.art.set_tile_at(0, 0, x, self.tile_height - 1, char, fg) # left/right edges char = self.border_vertical_line_char for y in range(self.tile_height): self.art.set_tile_at(0, 0, 0, y, char, fg) - self.art.set_tile_at(0, 0, self.tile_width-1, y, char, fg) + self.art.set_tile_at(0, 0, self.tile_width - 1, y, char, fg) # corners: bottom left, bottom right, top right char = self.border_corner_char x, y = 0, self.tile_height - 1 @@ -151,32 +159,33 @@ class PulldownMenu(UIElement): for x in range(1, len(menu_button.caption) + 2): self.art.set_tile_at(0, 0, x, 0, 0) self.art.set_tile_at(0, 0, x, 0, char, fg, None, UV_FLIPY) - + def get_shortcut(self, menu_item): # get InputLord's binding from given menu item's command name, # return concise string for bind and the actual function it runs. def null(): pass + # special handling of SeparatorMenuItem, no command or label if menu_item is SeparatorItem: - return '', null + return "", null binds = self.ui.app.il.edit_binds for bind_tuple in binds: command_functions = binds[bind_tuple] for f in command_functions: - if f.__name__ == 'BIND_%s' % menu_item.command: - shortcut = '' + if f.__name__ == "BIND_%s" % menu_item.command: + shortcut = "" # shift, alt, ctrl if bind_tuple[1]: - shortcut += 'Shift-' + shortcut += "Shift-" if bind_tuple[2]: - shortcut += 'Alt-' + shortcut += "Alt-" if bind_tuple[3]: # TODO: cmd vs ctrl for mac vs non - shortcut += 'C-' + shortcut += "C-" # bind strings that start with _ will be disregarded - if not (bind_tuple[0].startswith('_') and len(bind_tuple[0]) > 1): + if not (bind_tuple[0].startswith("_") and len(bind_tuple[0]) > 1): shortcut += bind_tuple[0] return shortcut, f - self.ui.app.log('Shortcut/command not found: %s' % menu_item.command) - return '', null + self.ui.app.log("Shortcut/command not found: %s" % menu_item.command) + return "", null diff --git a/ui_menu_pulldown_item.py b/ui_menu_pulldown_item.py index be62b36..d5acaea 100644 --- a/ui_menu_pulldown_item.py +++ b/ui_menu_pulldown_item.py @@ -1,16 +1,25 @@ -from renderable import LAYER_VIS_FULL, LAYER_VIS_DIM, LAYER_VIS_NONE - -from ui_tool import PencilTool, EraseTool, RotateTool, GrabTool, TextTool, SelectTool, PasteTool, FillTool +from renderable import LAYER_VIS_DIM, LAYER_VIS_FULL, LAYER_VIS_NONE +from ui_tool import ( + EraseTool, + FillTool, + GrabTool, + PasteTool, + PencilTool, + RotateTool, + SelectTool, + TextTool, +) # # specific pulldown menu items, eg File > Save, Edit > Copy # + class PulldownMenuItem: # label that displays for this item - label = 'Test Menu Item' + label = "Test Menu Item" # bindable command we look up from InputLord to get binding text from - command = 'test_command' + command = "test_command" # if not None, passed to button's cb_arg cb_arg = None # if True, pulldown button creation process won't auto-pad @@ -23,225 +32,291 @@ class PulldownMenuItem: art_mode_allowed = True # item is allowed in Game Mode game_mode_allowed = True + def should_dim(app): "returns True if this item should be dimmed based on current application state" # so many commands are inapplicable with no active art, default to dimming an # item if this is the case return app.ui.active_art is None + def get_label(app): "returns custom generated label based on app state" return None + class SeparatorItem(PulldownMenuItem): "menu separator, non-interactive and handled specially by menu drawing" + pass + class ArtModePulldownMenuItem(PulldownMenuItem): # unless overridden, art mode items not allowed in game mode game_mode_allowed = False + # # file menu # class FileNewItem(ArtModePulldownMenuItem): - label = 'New…' - command = 'new_art' + label = "New…" + command = "new_art" always_active = True + class FileOpenItem(ArtModePulldownMenuItem): - label = 'Open…' - command = 'open_art' + label = "Open…" + command = "open_art" always_active = True + class FileSaveItem(ArtModePulldownMenuItem): - label = 'Save' - command = 'save_current' + label = "Save" + command = "save_current" + def should_dim(app): return not app.ui.active_art or not app.ui.active_art.unsaved_changes + class FileSaveAsItem(ArtModePulldownMenuItem): - label = 'Save As…' - command = 'save_art_as' + label = "Save As…" + command = "save_art_as" + def should_dim(app): return app.ui.active_art is None + class FileCloseItem(ArtModePulldownMenuItem): - label = 'Close' - command = 'close_art' + label = "Close" + command = "close_art" + def should_dim(app): return app.ui.active_art is None + class FileRevertItem(ArtModePulldownMenuItem): - label = 'Revert' - command = 'revert_art' + label = "Revert" + command = "revert_art" + def should_dim(app): return app.ui.active_art is None or not app.ui.active_art.unsaved_changes + class FileImportItem(ArtModePulldownMenuItem): - label = 'Import…' - command = 'import_file' + label = "Import…" + command = "import_file" always_active = True + class FileExportItem(ArtModePulldownMenuItem): - label = 'Export…' - command = 'export_file' + label = "Export…" + command = "export_file" + def should_dim(app): return app.ui.active_art is None + class FileExportLastItem(ArtModePulldownMenuItem): - label = 'Export last' - command = 'export_file_last' + label = "Export last" + command = "export_file_last" + def should_dim(app): return app.ui.active_art is None + class FileConvertImageItem(ArtModePulldownMenuItem): - label = 'Convert Image…' - command = 'convert_image' + label = "Convert Image…" + command = "convert_image" + def should_dim(app): return app.ui.active_art is None + class FileQuitItem(ArtModePulldownMenuItem): - label = 'Quit' - command = 'quit' + label = "Quit" + command = "quit" always_active = True game_mode_allowed = True + # # edit menu # class EditUndoItem(ArtModePulldownMenuItem): - label = 'Undo' - command = 'undo' + label = "Undo" + command = "undo" + def should_dim(app): - return not app.ui.active_art or len(app.ui.active_art.command_stack.undo_commands) == 0 + return ( + not app.ui.active_art + or len(app.ui.active_art.command_stack.undo_commands) == 0 + ) + class EditRedoItem(ArtModePulldownMenuItem): - label = 'Redo' - command = 'redo' + label = "Redo" + command = "redo" + def should_dim(app): - return not app.ui.active_art or len(app.ui.active_art.command_stack.redo_commands) == 0 + return ( + not app.ui.active_art + or len(app.ui.active_art.command_stack.redo_commands) == 0 + ) + class EditCutItem(ArtModePulldownMenuItem): - label = 'Cut' - command = 'cut_selection' + label = "Cut" + command = "cut_selection" + def should_dim(app): return len(app.ui.select_tool.selected_tiles) == 0 + class EditCopyItem(ArtModePulldownMenuItem): - label = 'Copy' - command = 'copy_selection' + label = "Copy" + command = "copy_selection" + def should_dim(app): return len(app.ui.select_tool.selected_tiles) == 0 + class EditPasteItem(ArtModePulldownMenuItem): - label = 'Paste' - command = 'select_paste_tool' + label = "Paste" + command = "select_paste_tool" + def should_dim(app): return len(app.ui.clipboard) == 0 + class EditDeleteItem(ArtModePulldownMenuItem): - label = 'Clear' - command = 'erase_selection_or_art' + label = "Clear" + command = "erase_selection_or_art" + class EditSelectAllItem(ArtModePulldownMenuItem): - label = 'Select All' - command = 'select_all' + label = "Select All" + command = "select_all" + class EditSelectNoneItem(ArtModePulldownMenuItem): - label = 'Select None' - command = 'select_none' + label = "Select None" + command = "select_none" + class EditSelectInvertItem(ArtModePulldownMenuItem): - label = 'Invert Selection' - command = 'select_invert' + label = "Invert Selection" + command = "select_invert" + class EditPreferences(ArtModePulldownMenuItem): - label = 'Preferences…' - command = 'edit_cfg' + label = "Preferences…" + command = "edit_cfg" + # # tool menu # + class ToolTogglePickerItem(ArtModePulldownMenuItem): # two spaces in front of each label to leave room for mark - label = 'Show char/color picker' - command = 'toggle_picker' + label = "Show char/color picker" + command = "toggle_picker" + class ToolTogglePickerHoldItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'toggle_picker_hold' + label = "blah" + command = "toggle_picker_hold" + def get_label(app): - return 'Picker toggle key: %s' % ['press', 'hold'][app.ui.popup_hold_to_show] + return "Picker toggle key: %s" % ["press", "hold"][app.ui.popup_hold_to_show] + class ToolSwapSelectedColors(ArtModePulldownMenuItem): - label = 'Swap selected fg/bg colors' - command = 'swap_fg_bg_colors' + label = "Swap selected fg/bg colors" + command = "swap_fg_bg_colors" + class ToolToggleArtToolbar(ArtModePulldownMenuItem): - label = ' Show toolbar' - command = 'toggle_art_toolbar' + label = " Show toolbar" + command = "toggle_art_toolbar" + def should_mark(ui): return ui.art_toolbar.visible + class ToolPaintItem(ArtModePulldownMenuItem): # two spaces in front of each label to leave room for mark - label = ' %s' % PencilTool.button_caption - command = 'select_pencil_tool' + label = " %s" % PencilTool.button_caption + command = "select_pencil_tool" + class ToolEraseItem(ArtModePulldownMenuItem): - label = ' %s' % EraseTool.button_caption - command = 'select_erase_tool' + label = " %s" % EraseTool.button_caption + command = "select_erase_tool" + class ToolRotateItem(ArtModePulldownMenuItem): - label = ' %s' % RotateTool.button_caption - command = 'select_rotate_tool' + label = " %s" % RotateTool.button_caption + command = "select_rotate_tool" + class ToolGrabItem(ArtModePulldownMenuItem): - label = ' %s' % GrabTool.button_caption - command = 'select_grab_tool' + label = " %s" % GrabTool.button_caption + command = "select_grab_tool" + class ToolTextItem(ArtModePulldownMenuItem): - label = ' %s' % TextTool.button_caption - command = 'select_text_tool' + label = " %s" % TextTool.button_caption + command = "select_text_tool" + class ToolSelectItem(ArtModePulldownMenuItem): - label = ' %s' % SelectTool.button_caption - command = 'select_select_tool' + label = " %s" % SelectTool.button_caption + command = "select_select_tool" + class ToolPasteItem(ArtModePulldownMenuItem): - label = ' %s' % PasteTool.button_caption - command = 'select_paste_tool' + label = " %s" % PasteTool.button_caption + command = "select_paste_tool" + class ToolFillItem(ArtModePulldownMenuItem): - label = ' %s' % FillTool.button_caption - command = 'select_fill_tool' + label = " %s" % FillTool.button_caption + command = "select_fill_tool" + class ToolIncreaseBrushSizeItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'increase_brush_size' + label = "blah" + command = "increase_brush_size" + def should_dim(app): # dim this item for tools where brush size doesn't apply if not app.ui.active_art or not app.ui.selected_tool.brush_size: return True + def get_label(app): if not app.ui.selected_tool.brush_size: - return 'Increase brush size' + return "Increase brush size" size = app.ui.selected_tool.brush_size + 1 - return 'Increase brush size to %s' % size + return "Increase brush size to %s" % size + class ToolDecreaseBrushSizeItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'decrease_brush_size' + label = "blah" + command = "decrease_brush_size" + def should_dim(app): if not app.ui.active_art or not app.ui.selected_tool.brush_size: return True return app.ui.selected_tool.brush_size <= 1 + def get_label(app): if not app.ui.selected_tool.brush_size: - return 'Decrease brush size' + return "Decrease brush size" size = app.ui.selected_tool.brush_size - 1 - return 'Decrease brush size to %s' % size + return "Decrease brush size to %s" % size + class ToolSettingsItem(ArtModePulldownMenuItem): # base class for tool settings toggle items @@ -249,373 +324,479 @@ class ToolSettingsItem(ArtModePulldownMenuItem): # blacklist specific tools return not app.ui.active_art or type(app.ui.selected_tool) in [SelectTool] + class ToolToggleAffectsCharItem(ToolSettingsItem): - label = ' Affects: character' - command = 'toggle_affects_char' + label = " Affects: character" + command = "toggle_affects_char" + def should_mark(ui): return ui.selected_tool.affects_char + class ToolToggleAffectsFGItem(ToolSettingsItem): - label = ' Affects: foreground color' - command = 'toggle_affects_fg' + label = " Affects: foreground color" + command = "toggle_affects_fg" + def should_mark(ui): return ui.selected_tool.affects_fg_color + class ToolToggleAffectsBGItem(ToolSettingsItem): - label = ' Affects: background color' - command = 'toggle_affects_bg' + label = " Affects: background color" + command = "toggle_affects_bg" + def should_mark(ui): return ui.selected_tool.affects_bg_color + class ToolToggleAffectsXformItem(ToolSettingsItem): - label = ' Affects: character xform' - command = 'toggle_affects_xform' + label = " Affects: character xform" + command = "toggle_affects_xform" + def should_mark(ui): return ui.selected_tool.affects_xform + # cycle between the 3 different fill boundary types class ToolSetFillBoundariesItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'cycle_fill_boundary_mode' + label = "blah" + command = "cycle_fill_boundary_mode" + def should_dim(app): - return not type(app.ui.selected_tool) is FillTool + return type(app.ui.selected_tool) is not FillTool + def get_label(app): - return 'Fill tool bounded by: %s' % app.ui.fill_tool.boundary_mode_names[app.ui.fill_tool.boundary_mode] + return ( + "Fill tool bounded by: %s" + % app.ui.fill_tool.boundary_mode_names[app.ui.fill_tool.boundary_mode] + ) # # view menu # class ViewToggleCRTItem(ArtModePulldownMenuItem): - label = ' CRT filter' - command = 'toggle_crt' + label = " CRT filter" + command = "toggle_crt" game_mode_allowed = True + def should_dim(app): return app.fb.disable_crt + def should_mark(ui): return ui.app.fb.crt + class ViewToggleGridItem(ArtModePulldownMenuItem): - label = ' Grid' - command = 'toggle_grid_visibility' + label = " Grid" + command = "toggle_grid_visibility" game_mode_allowed = True + def should_mark(ui): return ui.app.grid.visible + class ViewBGTextureItem(ArtModePulldownMenuItem): - label = ' Textured background' - command = 'toggle_bg_texture' + label = " Textured background" + command = "toggle_bg_texture" always_active = True + def should_mark(ui): return ui.app.show_bg_texture + class ViewToggleZoomExtentsItem(ArtModePulldownMenuItem): - label = ' Zoom to Art extents' - command = 'toggle_zoom_extents' + label = " Zoom to Art extents" + command = "toggle_zoom_extents" + def should_mark(ui): return ui.active_art and ui.active_art.camera_zoomed_extents + class ViewZoomInItem(ArtModePulldownMenuItem): - label = 'Zoom in' - command = 'camera_zoom_in_proportional' + label = "Zoom in" + command = "camera_zoom_in_proportional" + class ViewZoomOutItem(ArtModePulldownMenuItem): - label = 'Zoom out' - command = 'camera_zoom_out_proportional' + label = "Zoom out" + command = "camera_zoom_out_proportional" + class ViewSetZoomItem(ArtModePulldownMenuItem): - label = 'Set camera zoom…' - command = 'set_camera_zoom' + label = "Set camera zoom…" + command = "set_camera_zoom" + class ViewToggleCameraTiltItem(ArtModePulldownMenuItem): - label = ' Camera tilt' - command = 'toggle_camera_tilt' + label = " Camera tilt" + command = "toggle_camera_tilt" always_active = True game_mode_allowed = True + def should_mark(ui): return ui.app.camera.y_tilt != 0 + class ViewSetOverlayImageItem(ArtModePulldownMenuItem): - label = 'Set overlay image…' - command = 'select_overlay_image' + label = "Set overlay image…" + command = "select_overlay_image" + class ViewToggleOverlayImageItem(ArtModePulldownMenuItem): - label = ' Toggle overlay image' - command = 'toggle_overlay_image' + label = " Toggle overlay image" + command = "toggle_overlay_image" + def should_mark(ui): return ui.app.draw_overlay + def should_dim(app): return app.overlay_renderable is None + class ViewSetOverlayImageOpacityItem(ArtModePulldownMenuItem): - label = 'Set overlay image opacity…' - command = 'set_overlay_image_opacity' + label = "Set overlay image opacity…" + command = "set_overlay_image_opacity" + def should_dim(app): return app.overlay_renderable is None or not app.draw_overlay + class ViewSetOverlayImageScalingItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'set_overlay_image_scaling' + label = "blah" + command = "set_overlay_image_scaling" + def get_label(app): - return 'Overlay image scaling: %s' % ['width', 'height', 'fill'][app.overlay_scale_type] + return ( + "Overlay image scaling: %s" + % ["width", "height", "fill"][app.overlay_scale_type] + ) + def should_dim(app): return app.overlay_renderable is None or not app.draw_overlay + # # art menu # class ArtOpenAllGameAssetsItem(ArtModePulldownMenuItem): - label = 'Open all Game Mode assets' - command = 'open_all_game_assets' + label = "Open all Game Mode assets" + command = "open_all_game_assets" + def should_dim(app): return len(app.gw.objects) == 0 + class ArtPreviousItem(ArtModePulldownMenuItem): - label = 'Previous Art' - command = 'previous_art' + label = "Previous Art" + command = "previous_art" + def should_dim(app): return len(app.art_loaded_for_edit) < 2 + class ArtNextItem(ArtModePulldownMenuItem): - label = 'Next Art' - command = 'next_art' + label = "Next Art" + command = "next_art" + def should_dim(app): return len(app.art_loaded_for_edit) < 2 + class ArtCropToSelectionItem(ArtModePulldownMenuItem): - label = 'Crop to selection' - command = 'crop_to_selection' + label = "Crop to selection" + command = "crop_to_selection" + def should_dim(app): return len(app.ui.select_tool.selected_tiles) == 0 + class ArtResizeItem(ArtModePulldownMenuItem): - label = 'Resize…' - command = 'resize_art' + label = "Resize…" + command = "resize_art" + class ArtFlipHorizontal(ArtModePulldownMenuItem): - label = 'Flip horizontally' - command = 'art_flip_horizontal' + label = "Flip horizontally" + command = "art_flip_horizontal" + class ArtFlipVertical(ArtModePulldownMenuItem): - label = 'Flip vertically' - command = 'art_flip_vertical' + label = "Flip vertically" + command = "art_flip_vertical" + class ArtToggleFlipAffectsXforms(ArtModePulldownMenuItem): - label = ' Flip affects xforms' - command = 'art_toggle_flip_affects_xforms' + label = " Flip affects xforms" + command = "art_toggle_flip_affects_xforms" + def should_mark(ui): return ui.flip_affects_xforms + class ArtRunScriptItem(ArtModePulldownMenuItem): - label = 'Run Artscript…' - command = 'run_art_script' - + label = "Run Artscript…" + command = "run_art_script" + + class ArtRunLastScriptItem(ArtModePulldownMenuItem): - label = 'Run last Artscript' - command = 'run_art_script_last' + label = "Run last Artscript" + command = "run_art_script_last" + def should_dim(app): return app.last_art_script is None + # # frame menu # class FramePreviousItem(ArtModePulldownMenuItem): - label = 'Previous frame' - command = 'previous_frame' + label = "Previous frame" + command = "previous_frame" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.frames < 2 + class FrameNextItem(ArtModePulldownMenuItem): - label = 'Next frame' - command = 'next_frame' + label = "Next frame" + command = "next_frame" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.frames < 2 + class FrameTogglePlaybackItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'toggle_anim_playback' + label = "blah" + command = "toggle_anim_playback" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.frames < 2 + def get_label(app): if not app.ui.active_art: - return 'Start animation playback' + return "Start animation playback" animating = app.ui.active_art.renderables[0].animating - return ['Start', 'Stop'][animating] + ' animation playback' + return ["Start", "Stop"][animating] + " animation playback" + class FrameToggleOnionItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'toggle_onion_visibility' + label = "blah" + command = "toggle_onion_visibility" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.frames < 2 + def get_label(app): - l = '%s onion skin frames' % ['Show', 'Hide'][app.onion_frames_visible] + l = "%s onion skin frames" % ["Show", "Hide"][app.onion_frames_visible] return l + class FrameCycleOnionFramesItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'cycle_onion_frames' + label = "blah" + command = "cycle_onion_frames" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.frames < 2 + def get_label(app): - return 'Number of onion frames: %s' % app.onion_show_frames + return "Number of onion frames: %s" % app.onion_show_frames + class FrameCycleOnionDisplayItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'cycle_onion_ahead_behind' + label = "blah" + command = "cycle_onion_ahead_behind" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.frames < 2 + def get_label(app): if app.onion_show_frames_behind and app.onion_show_frames_ahead: - display = 'Next & Previous' + display = "Next & Previous" elif app.onion_show_frames_behind: - display = 'Previous' + display = "Previous" else: - display = 'Next' - return 'Onion frames show: %s' % display + display = "Next" + return "Onion frames show: %s" % display + class FrameAddFrameItem(ArtModePulldownMenuItem): - label = 'Add frame…' - command = 'add_frame' + label = "Add frame…" + command = "add_frame" + class FrameDuplicateFrameItem(ArtModePulldownMenuItem): - label = 'Duplicate this frame…' - command = 'duplicate_frame' + label = "Duplicate this frame…" + command = "duplicate_frame" + class FrameChangeDelayItem(ArtModePulldownMenuItem): label = "Change this frame's hold time…" - command = 'change_frame_delay' + command = "change_frame_delay" + class FrameChangeDelayAllItem(ArtModePulldownMenuItem): label = "Change all frames' hold times…" - command = 'change_frame_delay_all' + command = "change_frame_delay_all" + class FrameChangeIndexItem(ArtModePulldownMenuItem): label = "Change this frame's index…" - command = 'change_frame_index' + command = "change_frame_index" + class FrameDeleteFrameItem(ArtModePulldownMenuItem): - label = 'Delete this frame' - command = 'delete_frame' + label = "Delete this frame" + command = "delete_frame" + def should_dim(app): # don't delete last frame return not app.ui.active_art or app.ui.active_art.frames < 2 + # # layer menu # class LayerAddItem(ArtModePulldownMenuItem): label = "Add layer…" - command = 'add_layer' + command = "add_layer" + class LayerDuplicateItem(ArtModePulldownMenuItem): label = "Duplicate this layer…" - command = 'duplicate_layer' + command = "duplicate_layer" + class LayerSetNameItem(ArtModePulldownMenuItem): label = "Change this layer's name…" - command = 'change_layer_name' + command = "change_layer_name" + class LayerSetZItem(ArtModePulldownMenuItem): label = "Change this layer's Z-depth…" - command = 'change_layer_z' + command = "change_layer_z" + class LayerToggleVisibleItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'toggle_layer_visibility' + label = "blah" + command = "toggle_layer_visibility" + def get_label(app): if not app.ui.active_art: - return 'Show this layer (Game Mode)' + return "Show this layer (Game Mode)" visible = app.ui.active_art.layers_visibility[app.ui.active_art.active_layer] - return ['Show', 'Hide'][visible] + ' this layer (Game Mode)' + return ["Show", "Hide"][visible] + " this layer (Game Mode)" + class LayerDeleteItem(ArtModePulldownMenuItem): label = "Delete this layer" - command = 'delete_layer' + command = "delete_layer" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.layers < 2 + class LayerSetInactiveVizItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'cycle_inactive_layer_visibility' + label = "blah" + command = "cycle_inactive_layer_visibility" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.layers < 2 + def get_label(app): - l = 'Inactive layers: ' + l = "Inactive layers: " if app.inactive_layer_visibility == LAYER_VIS_FULL: - return l + 'Visible' + return l + "Visible" elif app.inactive_layer_visibility == LAYER_VIS_DIM: - return l + 'Dim' + return l + "Dim" elif app.inactive_layer_visibility == LAYER_VIS_NONE: - return l + 'Invisible' + return l + "Invisible" + class LayerShowHiddenItem(ArtModePulldownMenuItem): - label = 'blah' - command = 'toggle_hidden_layers_visible' + label = "blah" + command = "toggle_hidden_layers_visible" + def get_label(app): - l = 'Art Mode-only layers: ' - l += ['Hidden', 'Visible'][app.show_hidden_layers] + l = "Art Mode-only layers: " + l += ["Hidden", "Visible"][app.show_hidden_layers] return l + class LayerPreviousItem(ArtModePulldownMenuItem): - label = 'Previous layer' - command = 'previous_layer' + label = "Previous layer" + command = "previous_layer" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.layers < 2 + class LayerNextItem(ArtModePulldownMenuItem): - label = 'Next layer' - command = 'next_layer' + label = "Next layer" + command = "next_layer" + def should_dim(app): return not app.ui.active_art or app.ui.active_art.layers < 2 + # # char/color menu # class ChooseCharSetItem(ArtModePulldownMenuItem): - label = 'Choose character set…' - command = 'choose_charset' + label = "Choose character set…" + command = "choose_charset" + class ChoosePaletteItem(ArtModePulldownMenuItem): - label = 'Choose palette…' - command = 'choose_palette' + label = "Choose palette…" + command = "choose_palette" + class PaletteFromImageItem(ArtModePulldownMenuItem): - label = 'Palette from image…' - command = 'palette_from_file' + label = "Palette from image…" + command = "palette_from_file" always_active = True + # # help menu # class HelpDocsItem(ArtModePulldownMenuItem): - label = 'Help (in browser)' - command = 'open_help_docs' + label = "Help (in browser)" + command = "open_help_docs" always_active = True close_on_select = True game_mode_allowed = True + class HelpGenerateDocsItem(ArtModePulldownMenuItem): - label = 'Generate documentation' - command = 'generate_docs' + label = "Generate documentation" + command = "generate_docs" close_on_select = True + def should_dim(app): return not app.pdoc_available + class HelpWebsiteItem(ArtModePulldownMenuItem): - label = 'Playscii website' - command = 'open_website' + label = "Playscii website" + command = "open_website" always_active = True close_on_select = True + # # menu data # class PulldownMenuData: "data for pulldown menus, eg File, Edit, etc; mainly a list of menu items" + items = [] + def should_mark_item(item, ui): "returns True if this item should be marked, subclasses have custom logic here" return False + def get_items(app): """ returns a list of items generated from app state, used for @@ -623,76 +804,137 @@ class PulldownMenuData: """ return [] + class FileMenuData(PulldownMenuData): - items = [FileNewItem, FileOpenItem, FileSaveItem, FileSaveAsItem, - FileCloseItem, FileRevertItem, SeparatorItem, FileImportItem, - FileExportItem, FileExportLastItem, SeparatorItem, FileQuitItem] + items = [ + FileNewItem, + FileOpenItem, + FileSaveItem, + FileSaveAsItem, + FileCloseItem, + FileRevertItem, + SeparatorItem, + FileImportItem, + FileExportItem, + FileExportLastItem, + SeparatorItem, + FileQuitItem, + ] + class EditMenuData(PulldownMenuData): - items = [EditUndoItem, EditRedoItem, SeparatorItem, - EditCutItem, EditCopyItem, EditPasteItem, EditDeleteItem, - SeparatorItem, EditSelectAllItem, - EditSelectNoneItem, EditSelectInvertItem, SeparatorItem, - EditPreferences] + items = [ + EditUndoItem, + EditRedoItem, + SeparatorItem, + EditCutItem, + EditCopyItem, + EditPasteItem, + EditDeleteItem, + SeparatorItem, + EditSelectAllItem, + EditSelectNoneItem, + EditSelectInvertItem, + SeparatorItem, + EditPreferences, + ] + class ToolMenuData(PulldownMenuData): - items = [ToolTogglePickerItem, ToolTogglePickerHoldItem, - ToolSwapSelectedColors, ToolToggleArtToolbar, SeparatorItem, - ToolPaintItem, ToolEraseItem, ToolRotateItem, ToolGrabItem, - ToolTextItem, ToolSelectItem, ToolPasteItem, ToolFillItem, SeparatorItem, - ToolIncreaseBrushSizeItem, ToolDecreaseBrushSizeItem, - ToolToggleAffectsCharItem, ToolToggleAffectsFGItem, - ToolToggleAffectsBGItem, ToolToggleAffectsXformItem, SeparatorItem, - ToolSetFillBoundariesItem - ] - # TODO: cycle char/color/xform items? + items = [ + ToolTogglePickerItem, + ToolTogglePickerHoldItem, + ToolSwapSelectedColors, + ToolToggleArtToolbar, + SeparatorItem, + ToolPaintItem, + ToolEraseItem, + ToolRotateItem, + ToolGrabItem, + ToolTextItem, + ToolSelectItem, + ToolPasteItem, + ToolFillItem, + SeparatorItem, + ToolIncreaseBrushSizeItem, + ToolDecreaseBrushSizeItem, + ToolToggleAffectsCharItem, + ToolToggleAffectsFGItem, + ToolToggleAffectsBGItem, + ToolToggleAffectsXformItem, + SeparatorItem, + ToolSetFillBoundariesItem, + ] + + # TODO: cycle char/color/xform items? # TODO: generate list from UI.tools instead of manually specified MenuItems def should_mark_item(item, ui): # if it's a tool setting toggle, use its own mark check function - if item.__bases__[0] is ToolSettingsItem: + if item.__bases__[0] is ToolSettingsItem or hasattr(item, "should_mark"): return item.should_mark(ui) - elif hasattr(item, 'should_mark'): # toolbar toggle, etc - return item.should_mark(ui) - return item.label == ' %s' % ui.selected_tool.button_caption + return item.label == " %s" % ui.selected_tool.button_caption + class ViewMenuData(PulldownMenuData): - items = [ViewToggleCRTItem, ViewToggleGridItem, ViewBGTextureItem, - SeparatorItem, - ViewToggleZoomExtentsItem, ViewZoomInItem, ViewZoomOutItem, - ViewSetZoomItem, ViewToggleCameraTiltItem, SeparatorItem, - ViewSetOverlayImageItem, ViewToggleOverlayImageItem, - ViewSetOverlayImageOpacityItem, ViewSetOverlayImageScalingItem - ] - + items = [ + ViewToggleCRTItem, + ViewToggleGridItem, + ViewBGTextureItem, + SeparatorItem, + ViewToggleZoomExtentsItem, + ViewZoomInItem, + ViewZoomOutItem, + ViewSetZoomItem, + ViewToggleCameraTiltItem, + SeparatorItem, + ViewSetOverlayImageItem, + ViewToggleOverlayImageItem, + ViewSetOverlayImageOpacityItem, + ViewSetOverlayImageScalingItem, + ] + def should_mark_item(item, ui): - if hasattr(item, 'should_mark'): + if hasattr(item, "should_mark"): return item.should_mark(ui) return False + class ArtMenuData(PulldownMenuData): - items = [ArtResizeItem, ArtCropToSelectionItem, - ArtFlipHorizontal, ArtFlipVertical, ArtToggleFlipAffectsXforms, - SeparatorItem, - ArtRunScriptItem, ArtRunLastScriptItem, SeparatorItem, - ArtOpenAllGameAssetsItem, SeparatorItem, - ArtPreviousItem, ArtNextItem, SeparatorItem] - + items = [ + ArtResizeItem, + ArtCropToSelectionItem, + ArtFlipHorizontal, + ArtFlipVertical, + ArtToggleFlipAffectsXforms, + SeparatorItem, + ArtRunScriptItem, + ArtRunLastScriptItem, + SeparatorItem, + ArtOpenAllGameAssetsItem, + SeparatorItem, + ArtPreviousItem, + ArtNextItem, + SeparatorItem, + ] + def should_mark_item(item, ui): "show checkmark for active art" - if hasattr(item, 'should_mark'): + if hasattr(item, "should_mark"): return item.should_mark(ui) return ui.active_art and ui.active_art.filename == item.cb_arg - + def get_items(app): "turn each loaded art into a menu item" items = [] for art in app.art_loaded_for_edit: # class just being used to store data, no need to spawn it - class TempMenuItemClass(ArtModePulldownMenuItem): pass + class TempMenuItemClass(ArtModePulldownMenuItem): + pass + item = TempMenuItemClass # leave spaces for mark - item.label = ' %s' % art.filename - item.command = 'art_switch_to' + item.label = " %s" % art.filename + item.command = "art_switch_to" item.cb_arg = art.filename # order list by art's time loaded item.time_loaded = art.time_loaded @@ -702,29 +944,47 @@ class ArtMenuData(PulldownMenuData): class FrameMenuData(PulldownMenuData): - items = [FrameAddFrameItem, FrameDuplicateFrameItem, - FrameChangeDelayItem, FrameChangeDelayAllItem, - FrameChangeIndexItem, FrameDeleteFrameItem, SeparatorItem, - FrameTogglePlaybackItem, FramePreviousItem, FrameNextItem, - SeparatorItem, - FrameToggleOnionItem, FrameCycleOnionFramesItem, - FrameCycleOnionDisplayItem] + items = [ + FrameAddFrameItem, + FrameDuplicateFrameItem, + FrameChangeDelayItem, + FrameChangeDelayAllItem, + FrameChangeIndexItem, + FrameDeleteFrameItem, + SeparatorItem, + FrameTogglePlaybackItem, + FramePreviousItem, + FrameNextItem, + SeparatorItem, + FrameToggleOnionItem, + FrameCycleOnionFramesItem, + FrameCycleOnionDisplayItem, + ] class LayerMenuData(PulldownMenuData): - - items = [LayerAddItem, LayerDuplicateItem, LayerSetNameItem, LayerSetZItem, - LayerDeleteItem, SeparatorItem, - LayerSetInactiveVizItem, LayerPreviousItem,LayerNextItem, - SeparatorItem, LayerToggleVisibleItem, LayerShowHiddenItem, - SeparatorItem] - + items = [ + LayerAddItem, + LayerDuplicateItem, + LayerSetNameItem, + LayerSetZItem, + LayerDeleteItem, + SeparatorItem, + LayerSetInactiveVizItem, + LayerPreviousItem, + LayerNextItem, + SeparatorItem, + LayerToggleVisibleItem, + LayerShowHiddenItem, + SeparatorItem, + ] + def should_mark_item(item, ui): "show checkmark for active art" if not ui.active_art: return False return ui.active_art.active_layer == item.cb_arg - + def get_items(app): "turn each layer into a menu item" items = [] @@ -741,29 +1001,33 @@ class LayerMenuData(PulldownMenuData): longest_line = len(item.label) + 1 # cap at max allowed line length longest_line = min(longest_line, 50) - for i,layer_name in enumerate(app.ui.active_art.layer_names): - class TempMenuItemClass(ArtModePulldownMenuItem): pass + for i, layer_name in enumerate(app.ui.active_art.layer_names): + + class TempMenuItemClass(ArtModePulldownMenuItem): + pass + item = TempMenuItemClass # leave spaces for mark - item.label = ' %s' % layer_name + item.label = " %s" % layer_name if not app.ui.active_art.layers_visibility[i]: - item.label += ' (hidden)' + item.label += " (hidden)" # pad, put Z depth on far right item.label = item.label.ljust(longest_line) # trim to keep below a max length item.label = item.label[:longest_line] # spaces between layer name and z depth - item.label += 'z:%.2f' % app.ui.active_art.layers_z[i] + item.label += "z:%.2f" % app.ui.active_art.layers_z[i] # tell PulldownMenu's button creation process not to auto-pad item.no_pad = True - item.command = 'layer_switch_to' + item.command = "layer_switch_to" item.cb_arg = i items.append(item) return items + class CharColorMenuData(PulldownMenuData): - items = [ChooseCharSetItem, ChoosePaletteItem, SeparatorItem, - PaletteFromImageItem] + items = [ChooseCharSetItem, ChoosePaletteItem, SeparatorItem, PaletteFromImageItem] + class HelpMenuData(PulldownMenuData): items = [HelpDocsItem, HelpGenerateDocsItem, HelpWebsiteItem] diff --git a/ui_object_panel.py b/ui_object_panel.py index 70ae028..5d30531 100644 --- a/ui_object_panel.py +++ b/ui_object_panel.py @@ -1,40 +1,44 @@ import os -from ui_button import UIButton, TEXT_RIGHT -from ui_edit_panel import GamePanel -from ui_dialog import UIDialog, Field +from ui_button import TEXT_RIGHT, UIButton from ui_colors import UIColors +from ui_dialog import Field, UIDialog +from ui_edit_panel import GamePanel class ResetObjectButton(UIButton): - caption = 'Reset object properties' + caption = "Reset object properties" caption_justify = TEXT_RIGHT + def selected(button): world = button.element.world world.reset_object_in_place(world.selected_objects[0]) class EditObjectPropertyDialog(UIDialog): - "dialog invoked by panel property click, modified at runtime as needed" - base_title = 'Set %s' - field0_base_label = 'New %s for %s:' + + base_title = "Set %s" + field0_base_label = "New %s for %s:" field_width = UIDialog.default_field_width fields = [ Field(label=field0_base_label, type=str, width=field_width, oneline=False) ] - confirm_caption = 'Set' + confirm_caption = "Set" center_in_window = False game_mode_visible = True - + def is_input_valid(self): - try: self.fields[0].type(self.field_texts[0]) - except: return False, '' + try: + self.fields[0].type(self.field_texts[0]) + except: + return False, "" return True, None - + def confirm_pressed(self): valid, reason = self.is_input_valid() - if not valid: return + if not valid: + return # set property for selected object(s) new_value = self.fields[0].type(self.field_texts[0]) for obj in self.ui.app.gw.selected_objects: @@ -49,17 +53,18 @@ class EditObjectPropertyButton(UIButton): class PropertyItem: - multi_value_text = '[various]' - + multi_value_text = "[various]" + def __init__(self, prop_name): self.prop_name = prop_name # property value & type filled in after creation self.prop_value = None self.prop_type = None + def set_value(self, value): # convert value to a button-friendly string if type(value) is float: - valstr = '%.3f' % value + valstr = "%.3f" % value # non-fixed decimal version may be shorter, if so use it if len(str(value)) < len(valstr): valstr = str(value) @@ -80,33 +85,36 @@ class PropertyItem: class EditObjectPanel(GamePanel): - "panel showing info for selected game object" + tile_width = 36 tile_height = 36 snap_right = True text_left = False base_button_classes = [ResetObjectButton] - + def __init__(self, ui): self.base_buttons = [] self.property_buttons = [] GamePanel.__init__(self, ui) - + def create_buttons(self): # buttons for persistent unique commands, eg reset object - for i,button_class in enumerate(self.base_button_classes): + for i, button_class in enumerate(self.base_button_classes): button = button_class(self) - button.caption += ' ' + button.caption += " " button.width = self.tile_width button.y = i + 1 button.callback = button.selected if button.clear_before_caption_draw: button.refresh_caption() self.base_buttons.append(button) + def callback(item=None): - if not item: return + if not item: + return self.clicked_item(item) + for y in range(self.tile_height - len(self.base_buttons) - 1): button = EditObjectPropertyButton(self) button.y = y + len(self.base_buttons) + 1 @@ -117,7 +125,7 @@ class EditObjectPanel(GamePanel): button.width = 10 self.property_buttons.append(button) self.buttons = self.base_buttons[:] + self.property_buttons[:] - + def clicked_item(self, item): # if property is a bool just toggle/set it, no need for a dialog if item.prop_type is bool: @@ -130,19 +138,28 @@ class EditObjectPanel(GamePanel): obj.set_object_property(item.prop_name, not val) return # set dialog values appropriate to property being edited - EditObjectPropertyDialog.title = EditObjectPropertyDialog.base_title % item.prop_name - + EditObjectPropertyDialog.title = ( + EditObjectPropertyDialog.base_title % item.prop_name + ) + # can't set named tuple values directly, build a new one and set it old_field = EditObjectPropertyDialog.fields[0] - new_label = EditObjectPropertyDialog.field0_base_label % (item.prop_type.__name__, item.prop_name) + new_label = EditObjectPropertyDialog.field0_base_label % ( + item.prop_type.__name__, + item.prop_name, + ) new_type = item.prop_type # if None, assume string if item.prop_type is type(None): new_type = str - new_field = Field(label=new_label, type=new_type, width=old_field.width, - oneline=old_field.oneline) + new_field = Field( + label=new_label, + type=new_type, + width=old_field.width, + oneline=old_field.oneline, + ) EditObjectPropertyDialog.fields[0] = new_field - + tile_x = int(self.ui.width_tiles * self.ui.scale) - self.tile_width tile_x -= EditObjectPropertyDialog.tile_width # give dialog a handle to item @@ -151,18 +168,18 @@ class EditObjectPanel(GamePanel): self.ui.active_dialog.field_texts[0] = str(item.prop_value) self.ui.active_dialog.tile_x, self.ui.active_dialog.tile_y = tile_x, self.tile_y self.ui.active_dialog.reset_loc() - + def get_label(self): # if 1 object seleted, show its name; if >1 selected, show # selected = len(self.world.selected_objects) # panel shouldn't draw when nothing selected, fill in anyway if selected == 0: - return '[nothing selected]' + return "[nothing selected]" elif selected == 1 and self.world.selected_objects[0]: return self.world.selected_objects[0].name else: - return '[%s selected]' % selected - + return "[%s selected]" % selected + def refresh_items(self): if len(self.world.selected_objects) == 0: return @@ -174,7 +191,7 @@ class EditObjectPanel(GamePanel): if obj is None: continue for propname in obj.serialized + obj.editable: - if not propname in propnames: + if propname not in propnames: propnames.append(propname) # build list of items from properties items = [] @@ -188,33 +205,33 @@ class EditObjectPanel(GamePanel): item.set_value(getattr(obj, propname)) items.append(item) # set each line - for i,b in enumerate(self.property_buttons): + for i, b in enumerate(self.property_buttons): item = None if i < len(items): item = items[i] self.draw_property_line(b, i, item) self.draw_buttons() - + def draw_property_line(self, button, button_index, item): "set button + label appearance correctly" y = button_index + len(self.base_buttons) + 1 self.art.clear_line(0, 0, y, self.fg_color, self.bg_color) if item is None: - button.caption = '' + button.caption = "" button.cb_arg = None button.can_hover = False return # set button caption, width, x based on value - button.caption = '%s ' % item.prop_value + button.caption = "%s " % item.prop_value button.width = len(button.caption) + 1 button.x = self.tile_width - button.width button.cb_arg = item button.can_hover = True # set non-button text to the left correctly x = button.x + 1 - label = '%s: ' % item.prop_name + label = "%s: " % item.prop_name self.art.write_string(0, 0, x, y, label, UIColors.darkgrey, None, True) - + def update(self): # redraw contents every update if self.is_visible(): @@ -222,6 +239,6 @@ class EditObjectPanel(GamePanel): self.refresh_items() GamePanel.update(self) self.renderable.alpha = 1 if self is self.ui.keyboard_focus_element else 0.5 - + def is_visible(self): return GamePanel.is_visible(self) and len(self.world.selected_objects) > 0 diff --git a/ui_popup.py b/ui_popup.py index 523b87f..3750cd6 100644 --- a/ui_popup.py +++ b/ui_popup.py @@ -1,103 +1,122 @@ - -from ui_element import UIElement, UIArt, UIRenderable -from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT -from ui_swatch import CharacterSetSwatch, PaletteSwatch, MIN_CHARSET_WIDTH +from art import UV_FLIPX, UV_FLIPY, UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270 +from renderable_line import SwatchSelectionBoxRenderable +from ui_button import TEXT_CENTER, UIButton from ui_colors import UIColors -from ui_tool import FillTool, FILL_BOUND_CHAR, FILL_BOUND_FG_COLOR, FILL_BOUND_BG_COLOR -from renderable_line import LineRenderable, SwatchSelectionBoxRenderable -from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY +from ui_element import UIArt, UIElement from ui_file_chooser_dialog import CharSetChooserDialog, PaletteChooserDialog +from ui_swatch import MIN_CHARSET_WIDTH, CharacterSetSwatch, PaletteSwatch +from ui_tool import FILL_BOUND_BG_COLOR, FILL_BOUND_CHAR, FILL_BOUND_FG_COLOR, FillTool TOOL_PANE_WIDTH = 10 + class ToolTabButton(UIButton): x, y = 0, 0 caption_y = 1 # width is set on the fly by popup size in reset_art height = 3 caption_justify = TEXT_CENTER - caption = 'Tools' + caption = "Tools" + class CharColorTabButton(UIButton): caption_y = 1 height = ToolTabButton.height caption_justify = TEXT_CENTER - caption = 'Chars/Colors' + caption = "Chars/Colors" + # charset view scale up/down buttons + class CharSetScaleUpButton(UIButton): width, height = 3, 1 x, y = -width, ToolTabButton.height + 1 - caption = '+' + caption = "+" caption_justify = TEXT_CENTER + class CharSetScaleDownButton(CharSetScaleUpButton): x = -CharSetScaleUpButton.width + CharSetScaleUpButton.x - caption = '-' + caption = "-" + # charset flip / rotate buttons + class CharXformButton(UIButton): hovered_fg_color = UIColors.white hovered_bg_color = UIColors.medgrey + class CharFlipNoButton(CharXformButton): - x = 3 + len('Flip:') + 1 + x = 3 + len("Flip:") + 1 y = CharSetScaleUpButton.y + 1 - caption = 'None' + caption = "None" width = len(caption) + 2 caption_justify = TEXT_CENTER + class CharFlipXButton(CharFlipNoButton): x = CharFlipNoButton.x + CharFlipNoButton.width + 1 width = 3 - caption = 'X' + caption = "X" + class CharFlipYButton(CharFlipXButton): x = CharFlipXButton.x + CharFlipXButton.width + 1 - caption = 'Y' + caption = "Y" + class CharRot0Button(CharXformButton): - x = 3 + len('Rotation:') + 1 + x = 3 + len("Rotation:") + 1 y = CharFlipNoButton.y + 1 width = 3 - caption = '0' + caption = "0" caption_justify = TEXT_CENTER + class CharRot90Button(CharRot0Button): x = CharRot0Button.x + CharRot0Button.width + 1 width = 4 - caption = '90' + caption = "90" + class CharRot180Button(CharRot0Button): x = CharRot90Button.x + CharRot90Button.width + 1 width = 5 - caption = '180' + caption = "180" + class CharRot270Button(CharRot0Button): x = CharRot180Button.x + CharRot180Button.width + 1 width = 5 - caption = '270' + caption = "270" + # tool and tool settings buttons + class ToolButton(UIButton): "a tool entry in the tool tab's left hand pane. populated from UI.tools" + width = TOOL_PANE_WIDTH - caption = 'TOOLZ' + caption = "TOOLZ" y = ToolTabButton.height + 2 + class BrushSizeUpButton(UIButton): width = 3 y = ToolTabButton.height + 3 - caption = '+' + caption = "+" caption_justify = TEXT_CENTER normal_fg_color = UIColors.white normal_bg_color = UIColors.medgrey + class BrushSizeDownButton(BrushSizeUpButton): - caption = '-' + caption = "-" + class AffectCharToggleButton(UIButton): width = 3 @@ -108,38 +127,46 @@ class AffectCharToggleButton(UIButton): normal_fg_color = UIColors.white normal_bg_color = UIColors.medgrey + class AffectFgToggleButton(AffectCharToggleButton): y = AffectCharToggleButton.y + 1 + class AffectBgToggleButton(AffectCharToggleButton): y = AffectCharToggleButton.y + 2 + class AffectXformToggleButton(AffectCharToggleButton): y = AffectCharToggleButton.y + 3 + # fill boundary mode items class FillBoundaryModeCharButton(AffectCharToggleButton): y = AffectXformToggleButton.y + 3 + class FillBoundaryModeFGButton(AffectCharToggleButton): y = FillBoundaryModeCharButton.y + 1 + class FillBoundaryModeBGButton(AffectCharToggleButton): y = FillBoundaryModeCharButton.y + 2 # charset / palette chooser buttons + class CharSetChooserButton(UIButton): - caption = 'Set:' + caption = "Set:" x = 1 normal_fg_color = UIColors.black normal_bg_color = UIColors.white hovered_fg_color = UIColors.white hovered_bg_color = UIColors.medgrey + class PaletteChooserButton(CharSetChooserButton): - caption = 'Palette:' + caption = "Palette:" TAB_TOOLS = 0 @@ -147,7 +174,6 @@ TAB_CHAR_COLOR = 1 class ToolPopup(UIElement): - visible = False # actual width will be based on character set + palette size and scale tile_width, tile_height = 20, 15 @@ -156,19 +182,19 @@ class ToolPopup(UIElement): fg_color = UIColors.black bg_color = UIColors.lightgrey highlight_color = UIColors.white - tool_settings_label = 'Tool Settings:' - brush_size_label = 'Brush size:' - affects_heading_label = 'Affects:' - affects_char_label = 'Character' - affects_fg_label = 'Foreground Color' - affects_bg_label = 'Background Color' - affects_xform_label = 'Rotation/Flip' - fill_boundary_modes_label = 'Fill boundary mode:' + tool_settings_label = "Tool Settings:" + brush_size_label = "Brush size:" + affects_heading_label = "Affects:" + affects_char_label = "Character" + affects_fg_label = "Foreground Color" + affects_bg_label = "Background Color" + affects_xform_label = "Rotation/Flip" + fill_boundary_modes_label = "Fill boundary mode:" fill_boundary_char_label = affects_char_label fill_boundary_fg_label = affects_fg_label fill_boundary_bg_label = affects_bg_label - flip_label = 'Flip:' - rotation_label = 'Rotation:' + flip_label = "Flip:" + rotation_label = "Rotation:" # index of check mark character in UI charset check_char_index = 131 # index of off and on radio button characters in UI charset @@ -176,36 +202,36 @@ class ToolPopup(UIElement): radio_char_1_index = 127 # map classes to member names / callbacks button_names = { - ToolTabButton: 'tool_tab', - CharColorTabButton: 'char_color_tab', + ToolTabButton: "tool_tab", + CharColorTabButton: "char_color_tab", } char_color_tab_button_names = { - CharSetScaleUpButton: 'scale_charset_up', - CharSetScaleDownButton: 'scale_charset_down', - CharSetChooserButton: 'choose_charset', - CharFlipNoButton: 'xform_normal', - CharFlipXButton: 'xform_flipX', - CharFlipYButton: 'xform_flipY', - CharRot0Button: 'xform_0', - CharRot90Button: 'xform_90', - CharRot180Button: 'xform_180', - CharRot270Button: 'xform_270', - PaletteChooserButton: 'choose_palette', + CharSetScaleUpButton: "scale_charset_up", + CharSetScaleDownButton: "scale_charset_down", + CharSetChooserButton: "choose_charset", + CharFlipNoButton: "xform_normal", + CharFlipXButton: "xform_flipX", + CharFlipYButton: "xform_flipY", + CharRot0Button: "xform_0", + CharRot90Button: "xform_90", + CharRot180Button: "xform_180", + CharRot270Button: "xform_270", + PaletteChooserButton: "choose_palette", } tool_tab_button_names = { - BrushSizeUpButton: 'brush_size_up', - BrushSizeDownButton: 'brush_size_down', - AffectCharToggleButton: 'toggle_affect_char', - AffectFgToggleButton: 'toggle_affect_fg', - AffectBgToggleButton: 'toggle_affect_bg', - AffectXformToggleButton: 'toggle_affect_xform', + BrushSizeUpButton: "brush_size_up", + BrushSizeDownButton: "brush_size_down", + AffectCharToggleButton: "toggle_affect_char", + AffectFgToggleButton: "toggle_affect_fg", + AffectBgToggleButton: "toggle_affect_bg", + AffectXformToggleButton: "toggle_affect_xform", } fill_boundary_mode_button_names = { - FillBoundaryModeCharButton: 'set_fill_boundary_char', - FillBoundaryModeFGButton: 'set_fill_boundary_fg', - FillBoundaryModeBGButton: 'set_fill_boundary_bg' + FillBoundaryModeCharButton: "set_fill_boundary_char", + FillBoundaryModeFGButton: "set_fill_boundary_fg", + FillBoundaryModeBGButton: "set_fill_boundary_bg", } - + def __init__(self, ui): self.ui = ui self.charset_swatch = CharacterSetSwatch(ui, self) @@ -219,19 +245,26 @@ class ToolPopup(UIElement): # create buttons from button:name map, button & callback names generated # group these into lists that can be combined into self.buttons self.common_buttons = self.create_buttons_from_map(self.button_names) - self.char_color_tab_buttons = self.create_buttons_from_map(self.char_color_tab_button_names) - self.fill_boundary_mode_buttons = self.create_buttons_from_map(self.fill_boundary_mode_button_names) - self.tool_tab_buttons = self.create_buttons_from_map(self.tool_tab_button_names) + self.fill_boundary_mode_buttons + self.char_color_tab_buttons = self.create_buttons_from_map( + self.char_color_tab_button_names + ) + self.fill_boundary_mode_buttons = self.create_buttons_from_map( + self.fill_boundary_mode_button_names + ) + self.tool_tab_buttons = ( + self.create_buttons_from_map(self.tool_tab_button_names) + + self.fill_boundary_mode_buttons + ) # populate more tool tab buttons from UI's list of tools # similar to create_buttons_from_map, but class name isn't known # MAYBE-TODO: is there a way to unify this? for tool in self.ui.tools: tool_button = ToolButton(self) # caption: 1-space padding from left - tool_button.caption = ' %s' % tool.button_caption - tool_button_name = '%s_tool_button' % tool.name + tool_button.caption = " %s" % tool.button_caption + tool_button_name = "%s_tool_button" % tool.name setattr(self, tool_button_name, tool_button) - cb_name = '%s_pressed' % tool_button_name + cb_name = "%s_pressed" % tool_button_name tool_button.callback = getattr(self, cb_name) # set a special property UI can refer to tool_button.tool_name = tool.name @@ -239,19 +272,21 @@ class ToolPopup(UIElement): UIElement.__init__(self, ui) # set initial tab state self.char_color_tab_button_pressed() - self.xform_0_button.normal_bg_color = self.xform_normal_button.normal_bg_color = self.highlight_color - + self.xform_0_button.normal_bg_color = ( + self.xform_normal_button.normal_bg_color + ) = self.highlight_color + def create_buttons_from_map(self, button_dict): buttons = [] for button_class in button_dict: button = button_class(self) - button_name = '%s_button' % button_dict[button_class] + button_name = "%s_button" % button_dict[button_class] setattr(self, button_name, button) - cb_name = '%s_pressed' % button_name + cb_name = "%s_pressed" % button_name button.callback = getattr(self, cb_name) buttons.append(button) return buttons - + def tool_tab_button_pressed(self): self.active_tab = TAB_TOOLS self.char_color_tab_button.can_hover = True @@ -261,7 +296,7 @@ class ToolPopup(UIElement): self.buttons = self.common_buttons + self.tool_tab_buttons self.draw_tool_tab() self.draw_buttons() - + def char_color_tab_button_pressed(self): self.active_tab = TAB_CHAR_COLOR self.tool_tab_button.can_hover = True @@ -271,79 +306,79 @@ class ToolPopup(UIElement): self.buttons = self.common_buttons + self.char_color_tab_buttons self.draw_char_color_tab() self.draw_buttons() - + def scale_charset_up_button_pressed(self): self.charset_swatch.increase_scale() self.reset_art() self.charset_swatch.reset_loc() self.palette_swatch.reset_loc() - + def scale_charset_down_button_pressed(self): self.charset_swatch.decrease_scale() self.reset_art() self.charset_swatch.reset_loc() self.palette_swatch.reset_loc() - + def brush_size_up_button_pressed(self): # any changes to tool's setting will force redraw of settings tab self.ui.selected_tool.increase_brush_size() - + def brush_size_down_button_pressed(self): self.ui.selected_tool.decrease_brush_size() - + def toggle_affect_char_button_pressed(self): self.ui.selected_tool.toggle_affects_char() - + def toggle_affect_fg_button_pressed(self): self.ui.selected_tool.toggle_affects_fg() - + def toggle_affect_bg_button_pressed(self): self.ui.selected_tool.toggle_affects_bg() - + def toggle_affect_xform_button_pressed(self): self.ui.selected_tool.toggle_affects_xform() - + def set_fill_boundary_char_button_pressed(self): self.ui.fill_tool.boundary_mode = FILL_BOUND_CHAR self.ui.tool_settings_changed = True - + def set_fill_boundary_fg_button_pressed(self): self.ui.fill_tool.boundary_mode = FILL_BOUND_FG_COLOR self.ui.tool_settings_changed = True - + def set_fill_boundary_bg_button_pressed(self): self.ui.fill_tool.boundary_mode = FILL_BOUND_BG_COLOR self.ui.tool_settings_changed = True - + def pencil_tool_button_pressed(self): self.ui.set_selected_tool(self.ui.pencil_tool) - + def erase_tool_button_pressed(self): self.ui.set_selected_tool(self.ui.erase_tool) - + def grab_tool_button_pressed(self): self.ui.set_selected_tool(self.ui.grab_tool) - + def rotate_tool_button_pressed(self): self.ui.set_selected_tool(self.ui.rotate_tool) - + def text_tool_button_pressed(self): self.ui.set_selected_tool(self.ui.text_tool) - + def select_tool_button_pressed(self): self.ui.set_selected_tool(self.ui.select_tool) - + def paste_tool_button_pressed(self): self.ui.set_selected_tool(self.ui.paste_tool) - + def fill_tool_button_pressed(self): self.ui.set_selected_tool(self.ui.fill_tool) - + def set_xform(self, new_xform): "tells UI elements to respect new xform" self.charset_swatch.set_xform(new_xform) self.update_xform_buttons() - + def update_xform_buttons(self): # light up button for current selected option button_map = { @@ -352,7 +387,7 @@ class ToolPopup(UIElement): UV_ROTATE180: self.xform_180_button, UV_ROTATE270: self.xform_270_button, UV_FLIPX: self.xform_flipX_button, - UV_FLIPY: self.xform_flipY_button + UV_FLIPY: self.xform_flipY_button, } for b in button_map: if b == self.ui.selected_xform: @@ -361,50 +396,56 @@ class ToolPopup(UIElement): button_map[b].normal_bg_color = self.bg_color self.xform_0_button.normal_bg_color = self.xform_normal_button.normal_bg_color self.draw_buttons() - + def xform_normal_button_pressed(self): self.ui.set_selected_xform(UV_NORMAL) - + def xform_flipX_button_pressed(self): self.ui.set_selected_xform(UV_FLIPX) - + def xform_flipY_button_pressed(self): self.ui.set_selected_xform(UV_FLIPY) - + def xform_0_button_pressed(self): self.ui.set_selected_xform(UV_NORMAL) - + def xform_90_button_pressed(self): self.ui.set_selected_xform(UV_ROTATE90) - + def xform_180_button_pressed(self): self.ui.set_selected_xform(UV_ROTATE180) - + def xform_270_button_pressed(self): self.ui.set_selected_xform(UV_ROTATE270) - + def choose_charset_button_pressed(self): self.hide() self.ui.open_dialog(CharSetChooserDialog) - + def choose_palette_button_pressed(self): self.hide() self.ui.open_dialog(PaletteChooserDialog) - + def draw_char_color_tab(self): "draw non-button bits of this tab" # charset renderable location will be set in update() charset = self.ui.active_art.charset palette = self.ui.active_art.palette - cqw, cqh = self.charset_swatch.art.quad_width, self.charset_swatch.art.quad_height + cqw, cqh = ( + self.charset_swatch.art.quad_width, + self.charset_swatch.art.quad_height, + ) self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color) # position & caption charset button y = self.tab_height + 1 self.choose_charset_button.y = y - self.choose_charset_button.caption = ' %s %s ' % (CharSetChooserButton.caption, charset.name) + self.choose_charset_button.caption = " %s %s " % ( + CharSetChooserButton.caption, + charset.name, + ) self.choose_charset_button.width = len(self.choose_charset_button.caption) # charset scale - charset_scale = '%.2fx' % self.charset_swatch.char_scale + charset_scale = "%.2fx" % self.charset_swatch.char_scale x = -self.scale_charset_up_button.width * 2 self.art.write_string(0, 0, x, y, charset_scale, None, None, True) # transform labels and buttons, eg @@ -419,14 +460,17 @@ class ToolPopup(UIElement): pal_caption_y = (cqh * charset.map_height) / self.art.quad_height pal_caption_y += self.tab_height + 5 self.choose_palette_button.y = int(pal_caption_y) - self.choose_palette_button.caption = ' %s %s ' % (PaletteChooserButton.caption, palette.name) + self.choose_palette_button.caption = " %s %s " % ( + PaletteChooserButton.caption, + palette.name, + ) self.choose_palette_button.width = len(self.choose_palette_button.caption) # set button states so captions draw properly tab_width = int(self.tile_width / 2) self.tool_tab_button.width = tab_width self.char_color_tab_button.width = int(self.tile_width) - tab_width self.char_color_tab_button.x = tab_width - + def draw_tool_tab(self): self.art.clear_frame_layer(0, 0, self.bg_color, self.fg_color) # fill tool bar with dimmer color, highlight selected tool @@ -435,7 +479,7 @@ class ToolPopup(UIElement): self.art.set_color_at(0, 0, x, y, self.ui.colors.medgrey, False) # set selected tool BG lighter y = self.tab_height + 1 - for i,tool in enumerate(self.ui.tools): + for i, tool in enumerate(self.ui.tools): tool_button = None for button in self.tool_tab_buttons: try: @@ -443,7 +487,7 @@ class ToolPopup(UIElement): tool_button = button except: pass - tool_button.y = y+i + tool_button.y = y + i if tool == self.ui.selected_tool: tool_button.normal_bg_color = self.ui.colors.lightgrey else: @@ -451,7 +495,10 @@ class ToolPopup(UIElement): # draw current tool settings x = TOOL_PANE_WIDTH + 1 y = self.tab_height + 1 - label = '%s %s' % (self.ui.selected_tool.button_caption, self.tool_settings_label) + label = "%s %s" % ( + self.ui.selected_tool.button_caption, + self.tool_settings_label, + ) self.art.write_string(0, 0, x, y, label) x += 1 y += 2 @@ -462,8 +509,8 @@ class ToolPopup(UIElement): label = self.brush_size_label # calculate X of + and - buttons based on size string self.brush_size_down_button.x = TOOL_PANE_WIDTH + len(label) + 2 - label += ' ' * (self.brush_size_down_button.width + 1) - label += '%s' % self.ui.selected_tool.brush_size + label += " " * (self.brush_size_down_button.width + 1) + label += "%s" % self.ui.selected_tool.brush_size self.brush_size_up_button.x = TOOL_PANE_WIDTH + len(label) + 3 self.art.write_string(0, 0, x, y, label) else: @@ -479,19 +526,29 @@ class ToolPopup(UIElement): y += 2 self.art.write_string(0, 0, x, y, self.affects_heading_label) y += 1 + # set affects-* button labels AND captions def get_affects_char(affects): return [0, self.check_char_index][affects] + w = self.toggle_affect_char_button.width label_toggle_pairs = [] - label_toggle_pairs += [(self.affects_char_label, self.ui.selected_tool.affects_char)] - label_toggle_pairs += [(self.affects_fg_label, self.ui.selected_tool.affects_fg_color)] - label_toggle_pairs += [(self.affects_bg_label, self.ui.selected_tool.affects_bg_color)] - label_toggle_pairs += [(self.affects_xform_label, self.ui.selected_tool.affects_xform)] - for label,toggle in label_toggle_pairs: - self.art.write_string(0, 0, x+w+1, y, '%s' % label) - #self.art.set_tile_at(0, 0, x, y, get_affects_char(toggle), 4, 2) - self.art.set_char_index_at(0, 0, x+1, y, get_affects_char(toggle)) + label_toggle_pairs += [ + (self.affects_char_label, self.ui.selected_tool.affects_char) + ] + label_toggle_pairs += [ + (self.affects_fg_label, self.ui.selected_tool.affects_fg_color) + ] + label_toggle_pairs += [ + (self.affects_bg_label, self.ui.selected_tool.affects_bg_color) + ] + label_toggle_pairs += [ + (self.affects_xform_label, self.ui.selected_tool.affects_xform) + ] + for label, toggle in label_toggle_pairs: + self.art.write_string(0, 0, x + w + 1, y, "%s" % label) + # self.art.set_tile_at(0, 0, x, y, get_affects_char(toggle), 4, 2) + self.art.set_char_index_at(0, 0, x + 1, y, get_affects_char(toggle)) y += 1 else: self.toggle_affect_char_button.visible = False @@ -504,21 +561,27 @@ class ToolPopup(UIElement): self.art.write_string(0, 0, x, y, self.fill_boundary_modes_label) y += 1 # boundary mode buttons + labels - #x += - labels = [self.fill_boundary_char_label, - self.fill_boundary_fg_label, - self.fill_boundary_bg_label] - for i,button in enumerate(self.fill_boundary_mode_buttons): + # x += + labels = [ + self.fill_boundary_char_label, + self.fill_boundary_fg_label, + self.fill_boundary_bg_label, + ] + for i, button in enumerate(self.fill_boundary_mode_buttons): button.visible = True - char = [self.radio_char_0_index, self.radio_char_1_index][i == self.ui.fill_tool.boundary_mode] - #self.ui.app.log(char) - self.art.set_char_index_at(0, 0, x+1, y, char) - self.art.write_string(0, 0, x + FillBoundaryModeCharButton.width + 1, y, labels[i]) + char = [self.radio_char_0_index, self.radio_char_1_index][ + i == self.ui.fill_tool.boundary_mode + ] + # self.ui.app.log(char) + self.art.set_char_index_at(0, 0, x + 1, y, char) + self.art.write_string( + 0, 0, x + FillBoundaryModeCharButton.width + 1, y, labels[i] + ) y += 1 else: for button in self.fill_boundary_mode_buttons: button.visible = False - + def reset_art(self): if not self.ui.active_art: return @@ -527,7 +590,10 @@ class ToolPopup(UIElement): # set panel size based on charset size margin = self.swatch_margin * 2 charset = self.ui.active_art.charset - cqw, cqh = self.charset_swatch.art.quad_width, self.charset_swatch.art.quad_height + cqw, cqh = ( + self.charset_swatch.art.quad_width, + self.charset_swatch.art.quad_height, + ) old_width, old_height = self.tile_width, self.tile_height # min width in case of tiny charsets charset_tile_width = max(charset.map_width, MIN_CHARSET_WIDTH) @@ -537,7 +603,10 @@ class ToolPopup(UIElement): # account for popup info lines etc: charset name + palette name + 1 padding each extra_lines = 7 # account for size of palette + bottom margin - palette_height = ((self.palette_swatch.art.height * self.palette_swatch.art.quad_height) + self.swatch_margin) / UIArt.quad_height + palette_height = ( + (self.palette_swatch.art.height * self.palette_swatch.art.quad_height) + + self.swatch_margin + ) / UIArt.quad_height self.tile_height += self.tab_height + palette_height + extra_lines if old_width != self.tile_width or old_height != self.tile_height: self.art.resize(int(self.tile_width), int(self.tile_height)) @@ -549,7 +618,7 @@ class ToolPopup(UIElement): self.update_xform_buttons() # draw button captions UIElement.reset_art(self) - + def show(self): # if already visible, bail - key repeat probably triggered this if self.visible: @@ -564,19 +633,22 @@ class ToolPopup(UIElement): if self.ui.pulldown.visible: self.ui.menu_bar.close_active_menu() self.reset_loc() - + def toggle(self): if self.visible: self.hide() else: self.show() - + def reset_loc(self): if not self.ui.active_art: return x, y = self.ui.get_screen_coords(self.ui.app.mouse_x, self.ui.app.mouse_y) # center on mouse - w, h = self.tile_width * self.art.quad_width, self.tile_height * self.art.quad_height + w, h = ( + self.tile_width * self.art.quad_width, + self.tile_height * self.art.quad_height, + ) x -= w / 2 y += h / 2 # clamp to edges of screen @@ -586,12 +658,12 @@ class ToolPopup(UIElement): self.renderable.x, self.renderable.y = self.x, self.y self.charset_swatch.reset_loc() self.palette_swatch.reset_loc() - + def hide(self): self.visible = False self.ui.keyboard_focus_element = None self.ui.refocus_keyboard() - + def set_active_charset(self, new_charset): self.charset_swatch.art.charset = new_charset self.palette_swatch.art.charset = new_charset @@ -602,7 +674,7 @@ class ToolPopup(UIElement): # charset width drives palette swatch width self.palette_swatch.reset() self.reset_art() - + def set_active_palette(self, new_palette): self.charset_swatch.art.palette = new_palette self.palette_swatch.art.palette = new_palette @@ -612,7 +684,7 @@ class ToolPopup(UIElement): self.ui.status_bar.set_active_palette(new_palette) self.palette_swatch.reset() self.reset_art() - + def update(self): UIElement.update(self) if not self.ui.active_art: @@ -624,7 +696,9 @@ class ToolPopup(UIElement): self.cursor_box.visible = True elif mouse_moved and self in self.ui.hovered_elements: self.cursor_box.visible = False - x, y = self.ui.get_screen_coords(self.ui.app.mouse_x, self.ui.app.mouse_y) + x, y = self.ui.get_screen_coords( + self.ui.app.mouse_x, self.ui.app.mouse_y + ) for e in [self.charset_swatch, self.palette_swatch]: if e.is_inside(x, y): self.cursor_box.visible = True @@ -636,23 +710,25 @@ class ToolPopup(UIElement): elif self.active_tab == TAB_TOOLS and self.ui.tool_settings_changed: self.draw_tool_tab() self.draw_buttons() - + def keyboard_navigate(self, dx, dy): - active_swatch = self.charset_swatch if self.cursor_char != -1 else self.palette_swatch + active_swatch = ( + self.charset_swatch if self.cursor_char != -1 else self.palette_swatch + ) # TODO: can't handle cross-swatch navigation properly, restrict to chars active_swatch = self.charset_swatch # reverse up/down direction active_swatch.move_cursor(self.cursor_box, dx, -dy) - + def keyboard_select_item(self): # called as ui.keyboard_focus_element # simulate left/right click in popup to select stuff self.select_key_pressed(self.ui.app.il.shift_pressed) - + def select_key_pressed(self, mod_pressed): mouse_button = [1, 3][mod_pressed] self.clicked(mouse_button) - + def clicked(self, mouse_button): handled = UIElement.clicked(self, mouse_button) if handled: @@ -669,7 +745,7 @@ class ToolPopup(UIElement): elif mouse_button == 3: self.ui.selected_bg_color = self.cursor_color return True - + def render(self): if not self.visible: return diff --git a/ui_status_bar.py b/ui_status_bar.py index fe9e543..af9ed49 100644 --- a/ui_status_bar.py +++ b/ui_status_bar.py @@ -1,77 +1,106 @@ -import os.path, time +import os.path +import time from math import ceil -from ui_element import UIElement, UIArt, UIRenderable -from ui_button import UIButton, TEXT_LEFT, TEXT_CENTER, TEXT_RIGHT -from ui_colors import UIColors -from renderable_line import UIRenderableX from art import uv_names +from renderable_line import UIRenderableX +from ui_button import TEXT_CENTER, TEXT_RIGHT, UIButton +from ui_colors import UIColors +from ui_element import UIArt, UIElement, UIRenderable # buttons to toggle "affects" status / cycle through choices, respectively + class StatusBarToggleButton(UIButton): caption_justify = TEXT_RIGHT + class StatusBarCycleButton(UIButton): # do different stuff for left vs right click pass_mouse_button = True should_draw_caption = False width = 3 + class CharToggleButton(StatusBarToggleButton): x = 0 - caption = 'ch:' + caption = "ch:" width = len(caption) + 1 tooltip_on_hover = True + def get_tooltip_text(self): - return 'character index: %s' % self.element.ui.selected_char + return "character index: %s" % self.element.ui.selected_char + def get_tooltip_location(self): return 1, self.element.get_tile_y() - 1 + class CharCycleButton(StatusBarCycleButton): x = CharToggleButton.width tooltip_on_hover = True + # reuse above - def get_tooltip_text(self): return CharToggleButton.get_tooltip_text(self) - def get_tooltip_location(self): return CharToggleButton.get_tooltip_location(self) + def get_tooltip_text(self): + return CharToggleButton.get_tooltip_text(self) + + def get_tooltip_location(self): + return CharToggleButton.get_tooltip_location(self) + class FGToggleButton(StatusBarToggleButton): x = CharCycleButton.x + CharCycleButton.width - caption = 'fg:' + caption = "fg:" width = len(caption) + 1 tooltip_on_hover = True + def get_tooltip_text(self): - return 'foreground color index: %s' % self.element.ui.selected_fg_color + return "foreground color index: %s" % self.element.ui.selected_fg_color + def get_tooltip_location(self): return 8, self.element.get_tile_y() - 1 + class FGCycleButton(StatusBarCycleButton): x = FGToggleButton.x + FGToggleButton.width tooltip_on_hover = True - def get_tooltip_text(self): return FGToggleButton.get_tooltip_text(self) - def get_tooltip_location(self): return FGToggleButton.get_tooltip_location(self) + + def get_tooltip_text(self): + return FGToggleButton.get_tooltip_text(self) + + def get_tooltip_location(self): + return FGToggleButton.get_tooltip_location(self) + class BGToggleButton(StatusBarToggleButton): x = FGCycleButton.x + FGCycleButton.width - caption = 'bg:' + caption = "bg:" width = len(caption) + 1 tooltip_on_hover = True + def get_tooltip_text(self): - return 'background color index: %s' % self.element.ui.selected_bg_color + return "background color index: %s" % self.element.ui.selected_bg_color + def get_tooltip_location(self): return 15, self.element.get_tile_y() - 1 + class BGCycleButton(StatusBarCycleButton): x = BGToggleButton.x + BGToggleButton.width tooltip_on_hover = True - def get_tooltip_text(self): return BGToggleButton.get_tooltip_text(self) - def get_tooltip_location(self): return BGToggleButton.get_tooltip_location(self) + + def get_tooltip_text(self): + return BGToggleButton.get_tooltip_text(self) + + def get_tooltip_location(self): + return BGToggleButton.get_tooltip_location(self) + class XformToggleButton(StatusBarToggleButton): x = BGCycleButton.x + BGCycleButton.width - caption = 'xform:' + caption = "xform:" width = len(caption) + 1 + # class for things like xform and tool whose captions you can cycle through class StatusBarTextCycleButton(StatusBarCycleButton): should_draw_caption = True @@ -83,32 +112,38 @@ class StatusBarTextCycleButton(StatusBarCycleButton): clicked_fg_color = UIColors.black clicked_bg_color = UIColors.white + class XformCycleButton(StatusBarTextCycleButton): x = XformToggleButton.x + XformToggleButton.width - width = len('Rotate 180') + width = len("Rotate 180") caption = uv_names[0] + class ToolCycleButton(StatusBarTextCycleButton): - x = XformCycleButton.x + XformCycleButton.width + len('tool:') + 1 + x = XformCycleButton.x + XformCycleButton.width + len("tool:") + 1 # width and caption are set during status bar init after button is created + class FileCycleButton(StatusBarTextCycleButton): - caption = '[nothing]' + caption = "[nothing]" + class LayerCycleButton(StatusBarTextCycleButton): - caption = 'X/Y' + caption = "X/Y" width = len(caption) + class FrameCycleButton(StatusBarTextCycleButton): - caption = 'X/Y' + caption = "X/Y" width = len(caption) + class ZoomSetButton(StatusBarTextCycleButton): - caption = '100.0' + caption = "100.0" width = len(caption) + class StatusBarUI(UIElement): - snap_bottom = True snap_left = True always_consume_input = True @@ -117,47 +152,71 @@ class StatusBarUI(UIElement): char_swatch_x = CharCycleButton.x fg_swatch_x = FGCycleButton.x bg_swatch_x = BGCycleButton.x - tool_label = 'tool:' + tool_label = "tool:" tool_label_x = XformCycleButton.x + XformCycleButton.width + 1 - tile_label = 'tile:' - layer_label = 'layer:' - frame_label = 'frame:' - zoom_label = '%' - right_items_width = len(tile_label) + len(layer_label) + len(frame_label) + (len('X/Y') + 2) * 2 + len('XX/YY') + 2 + len(zoom_label) + 10 + tile_label = "tile:" + layer_label = "layer:" + frame_label = "frame:" + zoom_label = "%" + right_items_width = ( + len(tile_label) + + len(layer_label) + + len(frame_label) + + (len("X/Y") + 2) * 2 + + len("XX/YY") + + 2 + + len(zoom_label) + + 10 + ) button_names = { - CharToggleButton: 'char_toggle', - CharCycleButton: 'char_cycle', - FGToggleButton: 'fg_toggle', - FGCycleButton: 'fg_cycle', - BGToggleButton: 'bg_toggle', - BGCycleButton: 'bg_cycle', - XformToggleButton: 'xform_toggle', - XformCycleButton: 'xform_cycle', - ToolCycleButton: 'tool_cycle', - FileCycleButton: 'file_cycle', - LayerCycleButton: 'layer_cycle', - FrameCycleButton: 'frame_cycle', - ZoomSetButton: 'zoom_set' + CharToggleButton: "char_toggle", + CharCycleButton: "char_cycle", + FGToggleButton: "fg_toggle", + FGCycleButton: "fg_cycle", + BGToggleButton: "bg_toggle", + BGCycleButton: "bg_cycle", + XformToggleButton: "xform_toggle", + XformCycleButton: "xform_cycle", + ToolCycleButton: "tool_cycle", + FileCycleButton: "file_cycle", + LayerCycleButton: "layer_cycle", + FrameCycleButton: "frame_cycle", + ZoomSetButton: "zoom_set", } - + def __init__(self, ui): art = ui.active_art self.ui = ui # create 3 custom Arts w/ source charset and palette, renderables for each - art_name = '%s_%s' % (int(time.time()), self.__class__.__name__) - self.char_art = UIArt(art_name, ui.app, art.charset, art.palette, self.swatch_width, 1) + art_name = "%s_%s" % (int(time.time()), self.__class__.__name__) + self.char_art = UIArt( + art_name, ui.app, art.charset, art.palette, self.swatch_width, 1 + ) self.char_renderable = UIRenderable(ui.app, self.char_art) - self.fg_art = UIArt(art_name, ui.app, art.charset, art.palette, self.swatch_width, 1) + self.fg_art = UIArt( + art_name, ui.app, art.charset, art.palette, self.swatch_width, 1 + ) self.fg_renderable = UIRenderable(ui.app, self.fg_art) - self.bg_art = UIArt(art_name, ui.app, art.charset, art.palette, self.swatch_width, 1) + self.bg_art = UIArt( + art_name, ui.app, art.charset, art.palette, self.swatch_width, 1 + ) self.bg_renderable = UIRenderable(ui.app, self.bg_art) # "dimmed out" box - self.dim_art = UIArt(art_name, ui.app, ui.charset, ui.palette, self.swatch_width + self.char_swatch_x, 1) + self.dim_art = UIArt( + art_name, + ui.app, + ui.charset, + ui.palette, + self.swatch_width + self.char_swatch_x, + 1, + ) self.dim_renderable = UIRenderable(ui.app, self.dim_art) self.dim_renderable.alpha = 0.75 # separate dimmed out box for xform, easier this way xform_width = XformToggleButton.width + XformCycleButton.width - self.dim_xform_art = UIArt(art_name, ui.app, ui.charset, ui.palette, xform_width, 1) + self.dim_xform_art = UIArt( + art_name, ui.app, ui.charset, ui.palette, xform_width, 1 + ) self.dim_xform_renderable = UIRenderable(ui.app, self.dim_xform_art) self.dim_xform_renderable.alpha = 0.75 # create clickable buttons @@ -165,19 +224,26 @@ class StatusBarUI(UIElement): self.button_map = {} for button_class, button_name in self.button_names.items(): button = button_class(self) - setattr(self, button_name + '_button', button) - cb_name = '%s_button_pressed' % button_name + setattr(self, button_name + "_button", button) + cb_name = "%s_button_pressed" % button_name button.callback = getattr(self, cb_name) self.buttons.append(button) # keep a mapping of button names to buttons, for eg tooltip updates self.button_map[button_name] = button # some button captions, widths, locations will be set in reset_art # determine total width of left-justified items - self.left_items_width = self.tool_cycle_button.x + self.tool_cycle_button.width + 15 + self.left_items_width = ( + self.tool_cycle_button.x + self.tool_cycle_button.width + 15 + ) # set some properties in bulk self.renderables = [] - for r in [self.char_renderable, self.fg_renderable, self.bg_renderable, - self.dim_renderable, self.dim_xform_renderable]: + for r in [ + self.char_renderable, + self.fg_renderable, + self.bg_renderable, + self.dim_renderable, + self.dim_xform_renderable, + ]: r.ui = ui r.grain_strength = 0 # add to list of renderables to manage eg destroyed on quit @@ -188,95 +254,112 @@ class StatusBarUI(UIElement): self.x_renderable.status_bar = self self.renderables.append(self.x_renderable) UIElement.__init__(self, ui) - + # button callbacks - + def char_toggle_button_pressed(self): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return self.ui.selected_tool.toggle_affects_char() - + def char_cycle_button_pressed(self, mouse_button): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.select_char(self.ui.selected_char + 1) elif mouse_button == 3: self.ui.select_char(self.ui.selected_char - 1) - + def fg_toggle_button_pressed(self): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return self.ui.selected_tool.toggle_affects_fg() - + def fg_cycle_button_pressed(self, mouse_button): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.select_fg(self.ui.selected_fg_color + 1) elif mouse_button == 3: self.ui.select_fg(self.ui.selected_fg_color - 1) - + def bg_toggle_button_pressed(self): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return self.ui.selected_tool.toggle_affects_bg() - + def bg_cycle_button_pressed(self, mouse_button): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.select_bg(self.ui.selected_bg_color + 1) elif mouse_button == 3: self.ui.select_bg(self.ui.selected_bg_color - 1) - + def xform_toggle_button_pressed(self): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return self.ui.selected_tool.toggle_affects_xform() - + def xform_cycle_button_pressed(self, mouse_button): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.cycle_selected_xform() elif mouse_button == 3: self.ui.cycle_selected_xform(True) # update caption with new xform self.xform_cycle_button.caption = uv_names[self.ui.selected_xform] - + def tool_cycle_button_pressed(self, mouse_button): - if self.ui.active_dialog: return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.cycle_selected_tool() elif mouse_button == 3: self.ui.cycle_selected_tool(True) self.tool_cycle_button.caption = self.ui.selected_tool.get_button_caption() - + def file_cycle_button_pressed(self, mouse_button): - if not self.ui.active_art: return - if self.ui.active_dialog: return + if not self.ui.active_art: + return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.next_active_art() elif mouse_button == 3: self.ui.previous_active_art() - + def layer_cycle_button_pressed(self, mouse_button): - if not self.ui.active_art: return - if self.ui.active_dialog: return + if not self.ui.active_art: + return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.set_active_layer(self.ui.active_art.active_layer + 1) elif mouse_button == 3: self.ui.set_active_layer(self.ui.active_art.active_layer - 1) - + def frame_cycle_button_pressed(self, mouse_button): - if not self.ui.active_art: return - if self.ui.active_dialog: return + if not self.ui.active_art: + return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.set_active_frame(self.ui.active_art.active_frame + 1) elif mouse_button == 3: self.ui.set_active_frame(self.ui.active_art.active_frame - 1) - + def zoom_set_button_pressed(self, mouse_button): - if not self.ui.active_art: return - if self.ui.active_dialog: return + if not self.ui.active_art: + return + if self.ui.active_dialog: + return if mouse_button == 1: self.ui.app.camera.zoom_proportional(1) elif mouse_button == 3: self.ui.app.camera.zoom_proportional(-1) - + def reset_art(self): UIElement.reset_art(self) self.tile_width = ceil(self.ui.width_tiles * self.ui.scale) @@ -297,7 +380,7 @@ class StatusBarUI(UIElement): self.char_art.geo_changed = True self.fg_art.geo_changed = True self.bg_art.geo_changed = True - + def rewrite_art(self): bg = self.ui.colors.white self.art.clear_frame_layer(0, 0, bg) @@ -305,8 +388,9 @@ class StatusBarUI(UIElement): if self.tile_width < self.left_items_width: return # draw tool label - self.art.write_string(0, 0, self.tool_label_x, 0, self.tool_label, - self.ui.palette.darkest_index) + self.art.write_string( + 0, 0, self.tool_label_x, 0, self.tool_label, self.ui.palette.darkest_index + ) # only draw right side info if the window is wide enough if self.art.width > self.left_items_width + self.right_items_width: self.file_cycle_button.visible = True @@ -319,19 +403,25 @@ class StatusBarUI(UIElement): self.layer_cycle_button.visible = False self.frame_cycle_button.visible = False self.zoom_set_button.visible = False - + def set_active_charset(self, new_charset): self.char_art.charset = self.fg_art.charset = self.bg_art.charset = new_charset self.reset_art() - + def set_active_palette(self, new_palette): self.char_art.palette = self.fg_art.palette = self.bg_art.palette = new_palette self.reset_art() - + def get_tile_y(self): "returns tile coordinate Y position of bar" - return int(self.ui.app.window_height / (self.ui.charset.char_height * self.ui.scale)) - 1 - + return ( + int( + self.ui.app.window_height + / (self.ui.charset.char_height * self.ui.scale) + ) + - 1 + ) + def update_button_captions(self): "set captions for buttons that change from selections" art = self.ui.active_art @@ -339,22 +429,24 @@ class StatusBarUI(UIElement): self.tool_cycle_button.caption = self.ui.selected_tool.get_button_caption() self.tool_cycle_button.width = len(self.tool_cycle_button.caption) + 2 # right edge elements - self.file_cycle_button.caption = os.path.basename(art.filename) if art else FileCycleButton.caption + self.file_cycle_button.caption = ( + os.path.basename(art.filename) if art else FileCycleButton.caption + ) self.file_cycle_button.width = len(self.file_cycle_button.caption) + 2 # NOTE: button X offsets will be set in write_right_elements - null = '---' + null = "---" layers = art.layers if art else 0 - layer = '%s/%s' % (art.active_layer + 1, layers) if art else null + layer = "%s/%s" % (art.active_layer + 1, layers) if art else null self.layer_cycle_button.caption = layer self.layer_cycle_button.width = len(self.layer_cycle_button.caption) frames = art.frames if art else 0 - frame = '%s/%s' % (art.active_frame + 1, frames) if art else null + frame = "%s/%s" % (art.active_frame + 1, frames) if art else null self.frame_cycle_button.caption = frame self.frame_cycle_button.width = len(self.frame_cycle_button.caption) # zoom % - zoom = '%.1f' % self.ui.app.camera.get_current_zoom_pct() if art else null - self.zoom_set_button.caption = zoom[:5] # maintain size - + zoom = "%.1f" % self.ui.app.camera.get_current_zoom_pct() if art else null + self.zoom_set_button.caption = zoom[:5] # maintain size + def update(self): if not self.ui.active_art: return @@ -379,14 +471,14 @@ class StatusBarUI(UIElement): art.update() self.rewrite_art() self.draw_buttons() - + def position_swatch(self, renderable, x_offset): renderable.x = (self.char_art.quad_width * x_offset) - 1 renderable.y = self.char_art.quad_height - 1 - + def reset_loc(self): UIElement.reset_loc(self) - + def write_right_elements(self): """ fills in right-justified parts of status bar, eg current @@ -406,7 +498,7 @@ class StatusBarUI(UIElement): self.zoom_set_button.x = x x -= padding # tile - tile = 'X/Y' + tile = "X/Y" color = light if self.ui.app.cursor and art: tile_x, tile_y = self.ui.app.cursor.get_tile() @@ -420,22 +512,22 @@ class StatusBarUI(UIElement): color = self.dim_color tile_x = str(tile_x).rjust(3) tile_y = str(tile_y).rjust(3) - tile = '%s,%s' % (tile_x, tile_y) + tile = "%s,%s" % (tile_x, tile_y) self.art.write_string(0, 0, x, 0, tile, color, dark, True) # tile label x -= len(tile) self.art.write_string(0, 0, x, 0, self.tile_label, dark, light, True) # position layer button - x -= (padding + len(self.tile_label) + self.layer_cycle_button.width) + x -= padding + len(self.tile_label) + self.layer_cycle_button.width self.layer_cycle_button.x = x # layer label self.art.write_string(0, 0, x, 0, self.layer_label, dark, light, True) # position frame button - x -= (padding + len(self.layer_label) + self.frame_cycle_button.width) + x -= padding + len(self.layer_label) + self.frame_cycle_button.width self.frame_cycle_button.x = x # frame label self.art.write_string(0, 0, x, 0, self.frame_label, dark, light, True) - + def render(self): if not self.ui.active_art: return diff --git a/ui_swatch.py b/ui_swatch.py index 1e65acb..ed346e4 100644 --- a/ui_swatch.py +++ b/ui_swatch.py @@ -1,25 +1,34 @@ -import math, time +import math +import time + import numpy as np -from ui_element import UIElement, UIArt, UIRenderable from renderable_line import LineRenderable, SwatchSelectionBoxRenderable, UIRenderableX +from ui_element import UIArt, UIElement, UIRenderable # min width for charset; if charset is tiny adjust to this MIN_CHARSET_WIDTH = 16 + class UISwatch(UIElement): - def __init__(self, ui, popup): self.ui = ui self.popup = popup self.reset() - + def reset(self): self.tile_width, self.tile_height = self.get_size() art = self.ui.active_art # generate a unique name for debug purposes - art_name = '%s_%s' % (int(time.time()), self.__class__.__name__) - self.art = UIArt(art_name, self.ui.app, art.charset, art.palette, self.tile_width, self.tile_height) + art_name = "%s_%s" % (int(time.time()), self.__class__.__name__) + self.art = UIArt( + art_name, + self.ui.app, + art.charset, + art.palette, + self.tile_width, + self.tile_height, + ) # tear down existing renderables if any if not self.renderables: self.renderables = [] @@ -32,20 +41,20 @@ class UISwatch(UIElement): self.renderable.grain_strength = 0 self.renderables.append(self.renderable) self.reset_art() - + def reset_art(self): pass - + def get_size(self): return 1, 1 - + def set_cursor_loc_from_mouse(self, cursor, mouse_x, mouse_y): # get location within char map w, h = self.art.quad_width, self.art.quad_height tile_x = (mouse_x - self.x) / w tile_y = (mouse_y - self.y) / h self.set_cursor_loc(cursor, tile_x, tile_y) - + def set_cursor_loc(self, cursor, tile_x, tile_y): """ common, generalized code for both character and palette swatches: @@ -68,64 +77,69 @@ class UISwatch(UIElement): cursor.quad_size_ref = self.art cursor.tile_x, cursor.tile_y = tile_x, tile_y cursor.x, cursor.y = x, y - + def is_selection_index_valid(self, index): "returns True if given index is valid for choices this swatch offers" return False - + def set_cursor_selection_index(self, index): "another set_cursor_loc support method, overriden by subclasses" self.popup.blah = index - + def render(self): self.renderable.render() class CharacterSetSwatch(UISwatch): - # scale the character set will be drawn at char_scale = 2 min_scale = 1 max_scale = 5 scale_increment = 0.25 - + def increase_scale(self): if self.char_scale <= self.max_scale - self.scale_increment: self.char_scale += self.scale_increment - + def decrease_scale(self): if self.char_scale >= self.min_scale + self.scale_increment: self.char_scale -= self.scale_increment - + def reset(self): UISwatch.reset(self) self.selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art) self.grid = CharacterGridRenderable(self.ui.app, self.art) self.create_shade() - self.renderables = [self.renderable, self.selection_box, self.grid, - self.shade] - + self.renderables = [self.renderable, self.selection_box, self.grid, self.shade] + def create_shade(self): # shaded box neath chars in case selected colors make em hard to see - self.shade_art = UIArt('charset_shade', self.ui.app, - self.ui.active_art.charset, self.ui.palette, - self.tile_width, self.tile_height) + self.shade_art = UIArt( + "charset_shade", + self.ui.app, + self.ui.active_art.charset, + self.ui.palette, + self.tile_width, + self.tile_height, + ) self.shade_art.clear_frame_layer(0, 0, self.ui.colors.black) self.shade = UIRenderable(self.ui.app, self.shade_art) self.shade.ui = self.ui self.shade.alpha = 0.2 - + def get_size(self): art = self.ui.active_art return art.charset.map_width, art.charset.map_height - + def reset_art(self): # MAYBE-TODO: using screen resolution, try to set quad size to an even # multiple of screen so the sampling doesn't get chunky aspect = self.ui.app.window_width / self.ui.app.window_height charset = self.art.charset self.art.quad_width = UIArt.quad_width * self.char_scale - self.art.quad_height = self.art.quad_width * (charset.char_height / charset.char_width) * aspect + self.art.quad_height = ( + self.art.quad_width * (charset.char_height / charset.char_width) * aspect + ) # only need to populate characters on reset_art, but update # colors every update() self.art.clear_frame_layer(0, 0, 0) @@ -135,7 +149,7 @@ class CharacterSetSwatch(UISwatch): self.art.set_char_index_at(0, 0, x, y, i) i += 1 self.art.geo_changed = True - + def reset_loc(self): self.x = self.popup.x + self.popup.swatch_margin self.y = self.popup.y @@ -144,38 +158,36 @@ class CharacterSetSwatch(UISwatch): self.grid.x, self.grid.y = self.x, self.y self.grid.y -= self.art.quad_height self.shade.x, self.shade.y = self.x, self.y - + def set_xform(self, new_xform): for y in range(self.art.height): for x in range(self.art.width): self.art.set_char_transform_at(0, 0, x, y, new_xform) - + def is_selection_index_valid(self, index): return index < self.art.charset.last_index - + def set_cursor_selection_index(self, index): self.popup.cursor_char = index self.popup.cursor_color = -1 - + def move_cursor(self, cursor, dx, dy): "moves cursor by specified amount in selection grid" # determine new cursor tile X/Y tile_x = cursor.tile_x + dx tile_y = cursor.tile_y + dy tile_index = (abs(tile_y) * self.art.width) + tile_x - if tile_x < 0 or tile_x >= self.art.width: - return - elif tile_y > 0: + if tile_x < 0 or tile_x >= self.art.width or tile_y > 0: return elif tile_y <= -self.art.height: # TODO: handle "jump" to palette swatch, and back - #cursor.tile_y = 0 - #self.popup.palette_swatch.move_cursor(cursor, 0, 0) + # cursor.tile_y = 0 + # self.popup.palette_swatch.move_cursor(cursor, 0, 0) return elif tile_index >= self.art.charset.last_index: return self.set_cursor_loc(cursor, tile_x, tile_y) - + def update(self): charset = self.ui.active_art.charset fg, bg = self.ui.selected_fg_color, self.ui.selected_bg_color @@ -185,7 +197,10 @@ class CharacterSetSwatch(UISwatch): for x in range(charset.map_width): self.art.set_tile_at(0, 0, x, y, None, fg, bg, xform) self.art.update() - if self.shade_art.quad_width != self.art.quad_width or self.shade_art.quad_height != self.art.quad_height: + if ( + self.shade_art.quad_width != self.art.quad_width + or self.shade_art.quad_height != self.art.quad_height + ): self.shade_art.quad_width = self.art.quad_width self.shade_art.quad_height = self.art.quad_height self.shade_art.geo_changed = True @@ -203,17 +218,18 @@ class CharacterSetSwatch(UISwatch): self.selection_box.y = self.renderable.y selection_y = (self.ui.selected_char - selection_x) / charset.map_width self.selection_box.y -= selection_y * self.art.quad_height - + def render_bg(self): # draw shaded box beneath swatch if selected color(s) too similar to BG def is_hard_to_see(other_color_index): - return self.ui.palette.are_colors_similar(self.popup.bg_color, - self.art.palette, - other_color_index) + return self.ui.palette.are_colors_similar( + self.popup.bg_color, self.art.palette, other_color_index + ) + fg, bg = self.ui.selected_fg_color, self.ui.selected_bg_color if is_hard_to_see(fg) or is_hard_to_see(bg): self.shade.render() - + def render(self): if not self.popup.visible: return @@ -224,27 +240,34 @@ class CharacterSetSwatch(UISwatch): class PaletteSwatch(UISwatch): - def reset(self): UISwatch.reset(self) self.transparent_x = UIRenderableX(self.ui.app, self.art) self.fg_selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art) self.bg_selection_box = SwatchSelectionBoxRenderable(self.ui.app, self.art) # F label for FG color selection - self.f_art = ColorSelectionLabelArt(self.ui, 'F') + self.f_art = ColorSelectionLabelArt(self.ui, "F") # make character dark self.f_art.set_color_at(0, 0, 0, 0, self.f_art.palette.darkest_index, True) self.f_renderable = ColorSelectionLabelRenderable(self.ui.app, self.f_art) self.f_renderable.ui = self.ui # B label for BG color seletion - self.b_art = ColorSelectionLabelArt(self.ui, 'B') + self.b_art = ColorSelectionLabelArt(self.ui, "B") self.b_renderable = ColorSelectionLabelRenderable(self.ui.app, self.b_art) self.b_renderable.ui = self.ui - self.renderables += self.transparent_x, self.fg_selection_box, self.bg_selection_box, self.f_renderable, self.b_renderable - + self.renderables += ( + self.transparent_x, + self.fg_selection_box, + self.bg_selection_box, + self.f_renderable, + self.b_renderable, + ) + def get_size(self): # balance rows/columns according to character set swatch width - charmap_width = max(self.popup.charset_swatch.art.charset.map_width, MIN_CHARSET_WIDTH) + charmap_width = max( + self.popup.charset_swatch.art.charset.map_width, MIN_CHARSET_WIDTH + ) colors = len(self.popup.charset_swatch.art.palette.colors) rows = math.ceil(colors / charmap_width) columns = math.ceil(colors / rows) @@ -252,10 +275,13 @@ class PaletteSwatch(UISwatch): if colors == 129 and columns == 15: columns = 16 return columns, rows - + def reset_art(self): # base our quad size on charset's - cqw, cqh = self.popup.charset_swatch.art.quad_width, self.popup.charset_swatch.art.quad_height + cqw, cqh = ( + self.popup.charset_swatch.art.quad_width, + self.popup.charset_swatch.art.quad_height, + ) # maximize item size based on row/column determined in get_size() charmap_width = max(self.art.charset.map_width, MIN_CHARSET_WIDTH) self.art.quad_width = (charmap_width / self.art.width) * cqw @@ -271,12 +297,15 @@ class PaletteSwatch(UISwatch): self.art.set_color_at(0, 0, x, y, i, False) i += 1 self.art.geo_changed = True - + def reset_loc(self): self.x = self.popup.x + self.popup.swatch_margin self.y = self.popup.charset_swatch.renderable.y # adjust Y for charset - self.y -= self.popup.charset_swatch.art.quad_height * self.ui.active_art.charset.map_height + self.y -= ( + self.popup.charset_swatch.art.quad_height + * self.ui.active_art.charset.map_height + ) # adjust Y for palette caption and character scale self.y -= self.popup.art.quad_height * 2 self.renderable.x, self.renderable.y = self.x, self.y @@ -294,23 +323,26 @@ class PaletteSwatch(UISwatch): self.transparent_x.y = self.renderable.y - self.art.quad_height self.transparent_x.y -= (h - 1) * self.art.quad_height # set f/b_art's quad size - self.f_art.quad_width, self.f_art.quad_height = self.b_art.quad_width, self.b_art.quad_height = self.popup.art.quad_width, self.popup.art.quad_height + self.f_art.quad_width, self.f_art.quad_height = ( + self.b_art.quad_width, + self.b_art.quad_height, + ) = self.popup.art.quad_width, self.popup.art.quad_height self.f_art.geo_changed = True self.b_art.geo_changed = True - + def is_selection_index_valid(self, index): return index < len(self.art.palette.colors) - + def set_cursor_selection_index(self, index): # modulo wrap if selecting last color self.popup.cursor_color = (index + 1) % len(self.art.palette.colors) self.popup.cursor_char = -1 - + def move_cursor(self, cursor, dx, dy): # similar enough to charset swatch's move_cursor, different enough to # merit this small bit of duplicate code pass - + def update(self): self.art.update() self.f_art.update() @@ -358,7 +390,7 @@ class PaletteSwatch(UISwatch): self.b_renderable.y = self.bg_selection_box.y self.b_renderable.x += x_offset self.b_renderable.y -= y_offset - + def render(self): if not self.popup.visible: return @@ -373,7 +405,7 @@ class PaletteSwatch(UISwatch): class ColorSelectionLabelArt(UIArt): def __init__(self, ui, letter): letter_index = ui.charset.get_char_index(letter) - art_name = '%s_%s' % (int(time.time()), self.__class__.__name__) + art_name = "%s_%s" % (int(time.time()), self.__class__.__name__) UIArt.__init__(self, art_name, ui.app, ui.charset, ui.palette, 1, 1) label_color = ui.colors.white label_bg_color = 0 @@ -386,9 +418,8 @@ class ColorSelectionLabelRenderable(UIRenderable): class CharacterGridRenderable(LineRenderable): - color = (0.5, 0.5, 0.5, 0.25) - + def build_geo(self): w, h = self.quad_size_ref.width, self.quad_size_ref.height v = [] @@ -396,12 +427,12 @@ class CharacterGridRenderable(LineRenderable): c = self.color * 4 * w * h index = 0 for x in range(1, w): - v += [(x, -h+1), (x, 1)] - e += [index, index+1] + v += [(x, -h + 1), (x, 1)] + e += [index, index + 1] index += 2 - for y in range(h-1): + for y in range(h - 1): v += [(w, -y), (0, -y)] - e += [index, index+1] + e += [index, index + 1] index += 2 self.vert_array = np.array(v, dtype=np.float32) self.elem_array = np.array(e, dtype=np.uint32) diff --git a/ui_tool.py b/ui_tool.py index 5f84eb6..8668575 100644 --- a/ui_tool.py +++ b/ui_tool.py @@ -1,18 +1,26 @@ -import math import sdl2 from PIL import Image -from texture import Texture +from art import ( + UV_FLIP90, + UV_FLIP270, + UV_FLIPX, + UV_FLIPY, + UV_NORMAL, + UV_ROTATE90, + UV_ROTATE180, + UV_ROTATE270, +) from edit_command import EditCommandTile -from art import UV_NORMAL, UV_ROTATE90, UV_ROTATE180, UV_ROTATE270, UV_FLIPX, UV_FLIPY, UV_FLIP90, UV_FLIP270 from key_shifts import SHIFT_MAP from selection import SelectionRenderable +from texture import Texture + class UITool: - - name = 'DEBUGTESTTOOL' + name = "DEBUGTESTTOOL" # name visible in popup's tool tab - button_caption = 'Debug Tool' + button_caption = "Debug Tool" # paint continuously, ie every time mouse enters a new tile paint_while_dragging = True # show preview of paint result under cursor @@ -25,8 +33,8 @@ class UITool: # (false for eg Selection tool) affects_masks = True # filename of icon in UI_ASSET_DIR, shown on cursor - icon_filename = 'icon.png' - + icon_filename = "icon.png" + def __init__(self, ui): self.ui = ui self.affects_char = True @@ -36,69 +44,89 @@ class UITool: # load icon, cursor's sprite renderable will reference this texture icon_filename = self.ui.asset_dir + self.icon_filename self.icon_texture = self.load_icon_texture(icon_filename) - + def load_icon_texture(self, img_filename): img = Image.open(img_filename) - img = img.convert('RGBA') + img = img.convert("RGBA") img = img.transpose(Image.FLIP_TOP_BOTTOM) return Texture(img.tobytes(), *img.size) - + def get_icon_texture(self): """ Returns icon texture that should display for tool's current state. (override to eg choose from multiples for mod keys) """ return self.icon_texture - + def get_button_caption(self): # normally just returns button_caption, but can be overridden to # provide custom behavior (eg fill tool) return self.button_caption - + def toggle_affects_char(self): if not self.affects_masks or self.ui.app.game_mode: return self.affects_char = not self.affects_char self.ui.tool_settings_changed = True - line = self.button_caption + ' ' - line = '%s %s' % (self.button_caption, [self.ui.affects_char_off_log, self.ui.affects_char_on_log][self.affects_char]) + line = self.button_caption + " " + line = "%s %s" % ( + self.button_caption, + [self.ui.affects_char_off_log, self.ui.affects_char_on_log][ + self.affects_char + ], + ) self.ui.message_line.post_line(line) - + def toggle_affects_fg(self): if not self.affects_masks or self.ui.app.game_mode: return self.affects_fg_color = not self.affects_fg_color self.ui.tool_settings_changed = True - line = '%s %s' % (self.button_caption, [self.ui.affects_fg_off_log, self.ui.affects_fg_on_log][self.affects_fg_color]) + line = "%s %s" % ( + self.button_caption, + [self.ui.affects_fg_off_log, self.ui.affects_fg_on_log][ + self.affects_fg_color + ], + ) self.ui.message_line.post_line(line) - + def toggle_affects_bg(self): if not self.affects_masks or self.ui.app.game_mode: return self.affects_bg_color = not self.affects_bg_color self.ui.tool_settings_changed = True - line = '%s %s' % (self.button_caption, [self.ui.affects_bg_off_log, self.ui.affects_bg_on_log][self.affects_bg_color]) + line = "%s %s" % ( + self.button_caption, + [self.ui.affects_bg_off_log, self.ui.affects_bg_on_log][ + self.affects_bg_color + ], + ) self.ui.message_line.post_line(line) - + def toggle_affects_xform(self): if not self.affects_masks or self.ui.app.game_mode: return self.affects_xform = not self.affects_xform self.ui.tool_settings_changed = True - line = '%s %s' % (self.button_caption, [self.ui.affects_xform_off_log, self.ui.affects_xform_on_log][self.affects_xform]) + line = "%s %s" % ( + self.button_caption, + [self.ui.affects_xform_off_log, self.ui.affects_xform_on_log][ + self.affects_xform + ], + ) self.ui.message_line.post_line(line) - + def get_paint_commands(self): "returns a list of EditCommandTiles for a given paint operation" return [] - + def increase_brush_size(self): if not self.brush_size: return self.brush_size += 1 self.ui.app.cursor.set_scale(self.brush_size) self.ui.tool_settings_changed = True - + def decrease_brush_size(self): if not self.brush_size: return @@ -109,12 +137,11 @@ class UITool: class PencilTool(UITool): - - name = 'pencil' + name = "pencil" # "Paint" not Pencil so the A mnemonic works :/ - button_caption = 'Paint' - icon_filename = 'tool_paint.png' - + button_caption = "Paint" + icon_filename = "tool_paint.png" + def get_tile_change(self, b_char, b_fg, b_bg, b_xform): """ return the tile value changes this tool would perform on a tile - @@ -123,12 +150,12 @@ class PencilTool(UITool): a_char = self.ui.selected_char if self.affects_char else None # don't paint fg color for blank characters # (disabled, see BB issue #86) - #a_fg = self.ui.selected_fg_color if self.affects_fg_color and a_char != 0 else None + # a_fg = self.ui.selected_fg_color if self.affects_fg_color and a_char != 0 else None a_fg = self.ui.selected_fg_color if self.affects_fg_color else None a_bg = self.ui.selected_bg_color if self.affects_bg_color else None a_xform = self.ui.selected_xform if self.affects_xform else None return a_char, a_fg, a_bg, a_xform - + def get_paint_commands(self): commands = [] art = self.ui.active_art @@ -137,8 +164,8 @@ class PencilTool(UITool): cur = self.ui.app.cursor # handle dragging while painting (cursor does the heavy lifting here) # !!TODO!! finish this, work in progress - if cur.moved_this_frame() and cur.current_command and False: #DEBUG - #print('%s: cursor moved' % self.ui.app.get_elapsed_time()) #DEBUG + if cur.moved_this_frame() and cur.current_command and False: # DEBUG + # print('%s: cursor moved' % self.ui.app.get_elapsed_time()) #DEBUG tiles = cur.get_tiles_under_drag() else: tiles = cur.get_tiles_under_brush() @@ -154,7 +181,9 @@ class PencilTool(UITool): new_tc.set_tile(frame, layer, *tile) b_char, b_fg, b_bg, b_xform = art.get_tile_at(frame, layer, *tile) new_tc.set_before(b_char, b_fg, b_bg, b_xform) - a_char, a_fg, a_bg, a_xform = self.get_tile_change(b_char, b_fg, b_bg, b_xform) + a_char, a_fg, a_bg, a_xform = self.get_tile_change( + b_char, b_fg, b_bg, b_xform + ) new_tc.set_after(a_char, a_fg, a_bg, a_xform) # Note: even if command has same result as another in command_tiles, # add it anyway as it may be a tool for which subsequent edits to @@ -165,11 +194,10 @@ class PencilTool(UITool): class EraseTool(PencilTool): - - name = 'erase' - button_caption = 'Erase' - icon_filename = 'tool_erase.png' - + name = "erase" + button_caption = "Erase" + icon_filename = "tool_erase.png" + def get_tile_change(self, b_char, b_fg, b_bg, b_xform): char = 0 if self.affects_char else None fg = 0 if self.affects_fg_color else None @@ -180,9 +208,8 @@ class EraseTool(PencilTool): class RotateTool(PencilTool): - - name = 'rotate' - button_caption = 'Rotate' + name = "rotate" + button_caption = "Rotate" update_preview_after_paint = True rotation_shifts = { UV_NORMAL: UV_ROTATE90, @@ -193,22 +220,21 @@ class RotateTool(PencilTool): UV_FLIPX: UV_FLIP270, UV_FLIP270: UV_FLIPY, UV_FLIPY: UV_ROTATE270, - UV_FLIP90: UV_FLIPX + UV_FLIP90: UV_FLIPX, } - icon_filename = 'tool_rotate.png' - + icon_filename = "tool_rotate.png" + def get_tile_change(self, b_char, b_fg, b_bg, b_xform): return b_char, b_fg, b_bg, self.rotation_shifts[b_xform] class GrabTool(UITool): - - name = 'grab' - button_caption = 'Grab' + name = "grab" + button_caption = "Grab" brush_size = None show_preview = False - icon_filename = 'tool_grab.png' - + icon_filename = "tool_grab.png" + def grab(self): x, y = self.ui.app.cursor.get_tile() art = self.ui.active_art @@ -233,44 +259,49 @@ class GrabTool(UITool): class TextTool(UITool): - - name = 'text' - button_caption = 'Text' + name = "text" + button_caption = "Text" brush_size = None show_preview = False - icon_filename = 'tool_text.png' - + icon_filename = "tool_text.png" + def __init__(self, ui): UITool.__init__(self, ui) self.input_active = False self.cursor = None - + def start_entry(self): self.cursor = self.ui.app.cursor # popup gobbles keyboard input, so always dismiss it if it's up if self.ui.popup.visible: self.ui.popup.hide() - if self.cursor.x < 0 or self.cursor.x > self.ui.active_art.width or \ - -self.cursor.y < 0 or -self.cursor.y > self.ui.active_art.height: + if ( + self.cursor.x < 0 + or self.cursor.x > self.ui.active_art.width + or -self.cursor.y < 0 + or -self.cursor.y > self.ui.active_art.height + ): return self.input_active = True self.reset_cursor_start(self.cursor.x, -self.cursor.y) self.cursor.start_paint() - #self.ui.message_line.post_line('Started text entry at %s, %s' % (self.start_x + 1, self.start_y + 1)) - self.ui.message_line.post_line('Started text entry, press Escape to stop entering text.', 5) - + # self.ui.message_line.post_line('Started text entry at %s, %s' % (self.start_x + 1, self.start_y + 1)) + self.ui.message_line.post_line( + "Started text entry, press Escape to stop entering text.", 5 + ) + def finish_entry(self): self.input_active = False self.ui.tool_settings_changed = True if self.cursor: x, y = int(self.cursor.x) + 1, int(-self.cursor.y) + 1 self.cursor.finish_paint() - #self.ui.message_line.post_line('Finished text entry at %s, %s' % (x, y)) - self.ui.message_line.post_line('Finished text entry.') - + # self.ui.message_line.post_line('Finished text entry at %s, %s' % (x, y)) + self.ui.message_line.post_line("Finished text entry.") + def reset_cursor_start(self, new_x, new_y): self.start_x, self.start_y = int(new_x), int(new_y) - + def handle_keyboard_input(self, key, shift_pressed, ctrl_pressed, alt_pressed): # for now, do nothing on ctrl/alt if ctrl_pressed or alt_pressed: @@ -284,30 +315,32 @@ class TextTool(UITool): x, y = int(self.cursor.x), int(-self.cursor.y) char_w, char_h = art.quad_width, art.quad_height # TODO: if cursor isn't inside selection, bail early - if keystr == 'Return': + if keystr == "Return": if self.cursor.y < art.width: self.cursor.x = self.start_x self.cursor.y -= 1 - elif keystr == 'Backspace': + elif keystr == "Backspace": if self.cursor.x > self.start_x: self.cursor.x -= char_w # undo command on previous tile - self.cursor.current_command.undo_commands_for_tile(frame, layer, x-1, y) - elif keystr == 'Space': - keystr = ' ' - elif keystr == 'Up': + self.cursor.current_command.undo_commands_for_tile( + frame, layer, x - 1, y + ) + elif keystr == "Space": + keystr = " " + elif keystr == "Up": if -self.cursor.y > 0: self.cursor.y += 1 - elif keystr == 'Down': + elif keystr == "Down": if -self.cursor.y < art.height - 1: self.cursor.y -= 1 - elif keystr == 'Left': + elif keystr == "Left": if self.cursor.x > 0: self.cursor.x -= char_w - elif keystr == 'Right': + elif keystr == "Right": if self.cursor.x < art.width - 1: self.cursor.x += char_w - elif keystr == 'Escape': + elif keystr == "Escape": self.finish_entry() return # ignore any other non-character keys @@ -322,9 +355,9 @@ class TextTool(UITool): if keystr.isalpha() and not shift_pressed and not self.ui.app.il.capslock_on: keystr = keystr.lower() elif not keystr.isalpha() and shift_pressed: - keystr = SHIFT_MAP.get(keystr, ' ') + keystr = SHIFT_MAP.get(keystr, " ") # if cursor got out of bounds, don't input - if 0 > x or x >= art.width or 0 > y or y >= art.height: + if x < 0 or x >= art.width or y < 0 or y >= art.height: return # create tile command new_tc = EditCommandTile(art) @@ -340,7 +373,7 @@ class TextTool(UITool): if self.cursor.current_command: self.cursor.current_command.add_command_tiles([new_tc]) else: - self.ui.app.log('DEV WARNING: Cursor current command was expected') + self.ui.app.log("DEV WARNING: Cursor current command was expected") new_tc.apply() self.cursor.x += char_w if self.cursor.x >= self.ui.active_art.width: @@ -351,17 +384,16 @@ class TextTool(UITool): class SelectTool(UITool): - - name = 'select' - button_caption = 'Select' + name = "select" + button_caption = "Select" brush_size = None affects_masks = False show_preview = False - icon_filename = 'tool_select_add.png' # used only for toolbar - icon_filename_normal = 'tool_select.png' - icon_filename_add = 'tool_select_add.png' - icon_filename_sub = 'tool_select_sub.png' - + icon_filename = "tool_select_add.png" # used only for toolbar + icon_filename_normal = "tool_select.png" + icon_filename_add = "tool_select_add.png" + icon_filename_sub = "tool_select_sub.png" + def __init__(self, ui): UITool.__init__(self, ui) self.selection_in_progress = False @@ -380,7 +412,7 @@ class SelectTool(UITool): self.icon_texture_add = self.load_icon_texture(icon) icon = self.ui.asset_dir + self.icon_filename_sub self.icon_texture_sub = self.load_icon_texture(icon) - + def get_icon_texture(self): # show different icons based on mod key status if self.ui.app.il.shift_pressed: @@ -389,14 +421,14 @@ class SelectTool(UITool): return self.icon_texture_sub else: return self.icon_texture - + def start_select(self): self.selection_in_progress = True self.current_drag = {} x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y) self.drag_start_x, self.drag_start_y = x, y - #print('started select drag at %s,%s' % (x, y)) - + # print('started select drag at %s,%s' % (x, y)) + def finish_select(self, add_to_selection, subtract_from_selection): self.selection_in_progress = False # selection boolean operations: @@ -410,9 +442,9 @@ class SelectTool(UITool): for tile in self.current_drag: self.selected_tiles.pop(tile, None) self.current_drag = {} - #x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y) - #print('finished select drag at %s,%s' % (x, y)) - + # x, y = self.ui.app.cursor.x, int(-self.ui.app.cursor.y) + # print('finished select drag at %s,%s' % (x, y)) + def update(self): if not self.ui.active_art: return @@ -423,9 +455,15 @@ class SelectTool(UITool): start_x, start_y = int(self.drag_start_x), int(self.drag_start_y) end_x, end_y = int(self.ui.app.cursor.x), int(-self.ui.app.cursor.y) if start_x > end_x: - start_x, end_x, = end_x, start_x + ( + start_x, + end_x, + ) = end_x, start_x if start_y > end_y: - start_y, end_y, = end_y, start_y + ( + start_y, + end_y, + ) = end_y, start_y # always grow to include cursor's tile end_x += 1 end_y += 1 @@ -445,7 +483,7 @@ class SelectTool(UITool): self.drag_renderable.rebind_buffers() self.last_selection = self.selected_tiles.copy() self.last_drag = self.current_drag.copy() - + def render_selections(self): if len(self.selected_tiles) > 0: self.select_renderable.render() @@ -454,12 +492,11 @@ class SelectTool(UITool): class PasteTool(UITool): - - name = 'paste' - button_caption = 'Paste' + name = "paste" + button_caption = "Paste" brush_size = None - icon_filename = 'tool_paste.png' - + icon_filename = "tool_paste.png" + # TODO!: dragging large pastes around seems heck of slow, investigate # why this function might be to blame and see if there's a fix! def get_paint_commands(self): @@ -488,7 +525,9 @@ class PasteTool(UITool): if len(self.ui.select_tool.selected_tiles) > 0: if not self.ui.select_tool.selected_tiles.get((x, y), False): continue - b_char, b_fg, b_bg, b_xform = self.ui.active_art.get_tile_at(frame, layer, x, y) + b_char, b_fg, b_bg, b_xform = self.ui.active_art.get_tile_at( + frame, layer, x, y + ) new_tc.set_before(b_char, b_fg, b_bg, b_xform) new_tc.set_tile(frame, layer, x, y) # respect affects masks like other tools @@ -502,35 +541,36 @@ class PasteTool(UITool): commands.append(new_tc) return commands + # "fill boundary" modes: character, fg color, bg color FILL_BOUND_CHAR = 0 FILL_BOUND_FG_COLOR = 1 FILL_BOUND_BG_COLOR = 2 + class FillTool(UITool): - - name = 'fill' - button_caption = 'Fill' + name = "fill" + button_caption = "Fill" brush_size = None - icon_filename = 'tool_fill_char.png' # used only for toolbar + icon_filename = "tool_fill_char.png" # used only for toolbar # icons and strings for different boundary modes - icon_filename_char = 'tool_fill_char.png' - icon_filename_fg = 'tool_fill_fg.png' - icon_filename_bg = 'tool_fill_bg.png' + icon_filename_char = "tool_fill_char.png" + icon_filename_fg = "tool_fill_fg.png" + icon_filename_bg = "tool_fill_bg.png" boundary_mode = FILL_BOUND_CHAR # user-facing names for the boundary modes boundary_mode_names = { - FILL_BOUND_CHAR : 'character', - FILL_BOUND_FG_COLOR : 'fg color', - FILL_BOUND_BG_COLOR : 'bg color' + FILL_BOUND_CHAR: "character", + FILL_BOUND_FG_COLOR: "fg color", + FILL_BOUND_BG_COLOR: "bg color", } # determine cycling order next_boundary_modes = { - FILL_BOUND_CHAR : FILL_BOUND_FG_COLOR, - FILL_BOUND_FG_COLOR : FILL_BOUND_BG_COLOR, - FILL_BOUND_BG_COLOR : FILL_BOUND_CHAR + FILL_BOUND_CHAR: FILL_BOUND_FG_COLOR, + FILL_BOUND_FG_COLOR: FILL_BOUND_BG_COLOR, + FILL_BOUND_BG_COLOR: FILL_BOUND_CHAR, } - + def __init__(self, ui): UITool.__init__(self, ui) icon = self.ui.asset_dir + self.icon_filename_char @@ -539,11 +579,15 @@ class FillTool(UITool): self.icon_texture_fg = self.load_icon_texture(icon) icon = self.ui.asset_dir + self.icon_filename_bg self.icon_texture_bg = self.load_icon_texture(icon) - + def get_icon_texture(self): # show different icon based on boundary type - return [self.icon_texture_char, self.icon_texture_fg, - self.icon_texture_bg][self.boundary_mode] - + return [self.icon_texture_char, self.icon_texture_fg, self.icon_texture_bg][ + self.boundary_mode + ] + def get_button_caption(self): - return '%s (%s bounded)' % (self.button_caption, self.boundary_mode_names[self.boundary_mode]) + return "%s (%s bounded)" % ( + self.button_caption, + self.boundary_mode_names[self.boundary_mode], + ) diff --git a/ui_toolbar.py b/ui_toolbar.py index e986eb6..aacb2b2 100644 --- a/ui_toolbar.py +++ b/ui_toolbar.py @@ -1,24 +1,21 @@ - -from ui_element import UIElement -from ui_button import UIButton - -from renderable_sprite import UISpriteRenderable from renderable_line import ToolSelectionBoxRenderable +from renderable_sprite import UISpriteRenderable +from ui_button import UIButton +from ui_element import UIElement class ToolBar(UIElement): - - tile_width, tile_height = 4, 1 # real size will be set based on buttons + tile_width, tile_height = 4, 1 # real size will be set based on buttons icon_scale_factor = 4 snap_left = True - + def __init__(self, ui): self.ui = ui self.icon_renderables = [] self.create_toolbar_buttons() UIElement.__init__(self, ui) self.selection_box = ToolSelectionBoxRenderable(ui.app, self.art) - + def reset_art(self): # by default, a 1D vertical bar self.tile_width = ToolBarButton.width @@ -26,7 +23,7 @@ class ToolBar(UIElement): self.tile_height = ToolBarButton.height * len(self.buttons) self.art.resize(self.tile_width, self.tile_height) UIElement.reset_art(self) - + def reset_loc(self): UIElement.reset_loc(self) # by default, a vertical bar centered along left edge of the screen @@ -36,19 +33,19 @@ class ToolBar(UIElement): self.renderable.x, self.renderable.y = self.x, self.y # scale and position button icons only now that we're positioned self.reset_button_icons() - + def create_toolbar_buttons(self): # (override in subclass) pass - + def update_selection_box(self): # (override in subclass) pass - + def update(self): UIElement.update(self) self.update_selection_box() - + def render(self): UIElement.render(self) for r in self.icon_renderables: @@ -58,42 +55,47 @@ class ToolBar(UIElement): class ToolBarButton(UIButton): width, height = 4, 2 - caption = '' + caption = "" tooltip_on_hover = True - + def get_tooltip_text(self): return self.cb_arg.button_caption - + def get_tooltip_location(self): x = self.width - window_height_chars = self.element.ui.app.window_height / (self.element.ui.charset.char_height * self.element.ui.scale) + window_height_chars = self.element.ui.app.window_height / ( + self.element.ui.charset.char_height * self.element.ui.scale + ) cursor_y = self.element.ui.app.mouse_y / self.element.ui.app.window_height y = int(cursor_y * window_height_chars) return x, y class ArtToolBar(ToolBar): - def create_toolbar_buttons(self): - for i,tool in enumerate(self.ui.tools): + for i, tool in enumerate(self.ui.tools): button = ToolBarButton(self) # button.caption = tool.button_caption # DEBUG button.x = 0 button.y = i * button.height # alternate colors - button.normal_bg_color = self.ui.colors.white if i % 2 == 0 else self.ui.colors.lightgrey + button.normal_bg_color = ( + self.ui.colors.white if i % 2 == 0 else self.ui.colors.lightgrey + ) button.hovered_bg_color = self.ui.colors.medgrey # callback: tell ui to set this tool as selected button.callback = self.ui.set_selected_tool button.cb_arg = tool self.buttons.append(button) # create button icon - sprite = UISpriteRenderable(self.ui.app, self.ui.asset_dir + tool.icon_filename) + sprite = UISpriteRenderable( + self.ui.app, self.ui.asset_dir + tool.icon_filename + ) self.icon_renderables.append(sprite) - + def reset_button_icons(self): button_height = self.art.quad_height * ToolBarButton.height - for i,icon in enumerate(self.icon_renderables): + for i, icon in enumerate(self.icon_renderables): # scale: same screen size as cursor icon scale_x = icon.texture.width / self.ui.app.window_width scale_x *= self.icon_scale_factor * self.ui.scale @@ -104,12 +106,12 @@ class ArtToolBar(ToolBar): # position # remember that in renderable space, (0, 0) = center of screen icon.x = self.x - icon.x += (icon.scale_x / 8) + icon.x += icon.scale_x / 8 icon.y = self.y icon.y -= button_height * i icon.y -= icon.scale_y - icon.y -= (icon.scale_y / 8) - + icon.y -= icon.scale_y / 8 + def update_selection_box(self): # scale and position box around currently selected tool self.selection_box.scale_x = ToolBarButton.width diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..18b0312 --- /dev/null +++ b/uv.lock @@ -0,0 +1,281 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + +[[package]] +name = "playscii" +version = "9.18" +source = { editable = "." } +dependencies = [ + { name = "appdirs" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyopengl" }, + { name = "pysdl2" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "appdirs" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyopengl" }, + { name = "pysdl2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyopengl" +version = "3.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, +] + +[[package]] +name = "pysdl2" +version = "0.9.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/ff/8704d84ad4d25f0a7bf7912504f64575e432e8d57dfba2fe35f5b2db7e04/pysdl2-0.9.17.tar.gz", hash = "sha256:48c6ef01a4eb123db5f7e46e1a1b565675755b07e615f3fe20a623c94735b52b", size = 775955, upload-time = "2024-12-30T18:07:27.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/e2/399ea7900e7510096aeb41439e6f1540bef0bdf7e15cc2d8464e4adb71e8/PySDL2-0.9.17-py3-none-any.whl", hash = "sha256:fe923dbf5c7b27bbc1eb2bf58abfa793f8f13fd7ae8b27b1bc2de49920bcbd41", size = 583137, upload-time = "2024-12-30T18:07:25.987Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, +] diff --git a/vector.py b/vector.py index 1179991..4770093 100644 --- a/vector.py +++ b/vector.py @@ -1,26 +1,26 @@ import math -import numpy as np -from OpenGL import GL, GLU +import numpy as np +from OpenGL import GLU + class Vec3: - "Basic 3D vector class. Not used very much currently." - + def __init__(self, x=0, y=0, z=0): self.x, self.y, self.z = x, y, z - + def __str__(self): - return 'Vec3 %.4f, %.4f, %.4f' % (self.x, self.y, self.z) - + return "Vec3 %.4f, %.4f, %.4f" % (self.x, self.y, self.z) + def __sub__(self, b): "Return a new vector subtracted from given other vector." return Vec3(self.x - b.x, self.y - b.y, self.z - b.z) - + def length(self): "Return this vector's scalar length." - return math.sqrt(self.x ** 2 + self.y ** 2 + self.z ** 2) - + return math.sqrt(self.x**2 + self.y**2 + self.z**2) + def normalize(self): "Return a unit length version of this vector." n = Vec3() @@ -31,26 +31,27 @@ class Vec3: n.y = self.y * ilength n.z = self.z * ilength return n - + def cross(self, b): "Return a new vector of cross product with given other vector." x = self.y * b.z - self.z * b.y y = self.z * b.x - self.x * b.z z = self.x * b.y - self.y * b.x return Vec3(x, y, z) - + def dot(self, b): "Return scalar dot product with given other vector." return self.x * b.x + self.y * b.y + self.z * b.z - + def inverse(self): "Return a new vector that is inverse of this vector." return Vec3(-self.x, -self.y, -self.z) - + def copy(self): "Return a copy of this vector." return Vec3(self.x, self.y, self.z) + def get_tiles_along_line(x0, y0, x1, y1): """ Return list of (x,y) tuples for all tiles crossing given worldspace @@ -63,7 +64,7 @@ def get_tiles_along_line(x0, y0, x1, y1): n = 1 if dx == 0: x_inc = 0 - error = float('inf') + error = float("inf") elif x1 > x0: x_inc = 1 n += math.floor(x1) - x @@ -74,7 +75,7 @@ def get_tiles_along_line(x0, y0, x1, y1): error = (x0 - math.floor(x0)) * dy if dy == 0: y_inc = 0 - error -= float('inf') + error -= float("inf") elif y1 > y0: y_inc = 1 n += math.floor(y1) - y @@ -95,11 +96,12 @@ def get_tiles_along_line(x0, y0, x1, y1): n -= 1 return tiles + def get_tiles_along_integer_line(x0, y0, x1, y1, cut_corners=True): """ simplified version of get_tiles_along_line using only integer math, also from http://playtechs.blogspot.com/2007/03/raytracing-on-grid.html - + cut_corners=True: when a 45 degree line is only intersecting the corners of two tiles, don't count them as overlapped. """ @@ -128,6 +130,7 @@ def get_tiles_along_integer_line(x0, y0, x1, y1, cut_corners=True): n -= 1 return tiles + def cut_xyz(x, y, z, threshold): """ Return input x,y,z with each axis clamped to 0 if it's close enough to @@ -138,10 +141,21 @@ def cut_xyz(x, y, z, threshold): z = z if abs(z) > threshold else 0 return x, y, z -def ray_plane_intersection(plane_x, plane_y, plane_z, - plane_dir_x, plane_dir_y, plane_dir_z, - ray_x, ray_y, ray_z, - ray_dir_x, ray_dir_y, ray_dir_z): + +def ray_plane_intersection( + plane_x, + plane_y, + plane_z, + plane_dir_x, + plane_dir_y, + plane_dir_z, + ray_x, + ray_y, + ray_z, + ray_dir_x, + ray_dir_y, + ray_dir_z, +): # from http://stackoverflow.com/a/39424162 plane = np.array([plane_x, plane_y, plane_z]) plane_dir = np.array([plane_dir_x, plane_dir_y, plane_dir_z]) @@ -149,13 +163,14 @@ def ray_plane_intersection(plane_x, plane_y, plane_z, ray_dir = np.array([ray_dir_x, ray_dir_y, ray_dir_z]) ndotu = plane_dir.dot(ray_dir) if abs(ndotu) < 0.000001: - #print ("no intersection or line is within plane") + # print ("no intersection or line is within plane") return 0, 0, 0 w = ray - plane si = -plane_dir.dot(w) / ndotu psi = w + si * ray_dir + plane return psi[0], psi[1], psi[2] + def screen_to_world(app, screen_x, screen_y): """ Return 3D (float) world space coordinates for given 2D (int) screen space @@ -174,12 +189,23 @@ def screen_to_world(app, screen_x, screen_y): # TODO: what Z is appropriate for game mode picking? test multiple planes? art = app.ui.active_art plane_z = art.layers_z[art.active_layer] if art and not app.game_mode else 0 - x, y, z = ray_plane_intersection(0, 0, plane_z, # plane loc - 0, 0, 1, # plane dir - end_x, end_y, end_z, # ray origin - dir_x, dir_y, dir_z) # ray dir + x, y, z = ray_plane_intersection( + 0, + 0, + plane_z, # plane loc + 0, + 0, + 1, # plane dir + end_x, + end_y, + end_z, # ray origin + dir_x, + dir_y, + dir_z, + ) # ray dir return x, y, z + def world_to_screen(app, world_x, world_y, world_z): """ Return 2D screen pixel space coordinates for given 3D (float) world space @@ -193,10 +219,11 @@ def world_to_screen(app, world_x, world_y, world_z): x, y, z = GLU.gluProject(world_x, world_y, world_z, vm, pjm, viewport) except: x, y, z = 0, 0, 0 - app.log('GLU.gluProject failed!') + app.log("GLU.gluProject failed!") # does Z mean anything here? return x, y + def world_to_screen_normalized(app, world_x, world_y, world_z): """ Return normalized (-1 to 1) 2D screen space coordinates for given 3D