#!/usr/bin/env python3 import os.path import sys if sys.version_info.major < 3: print("Python 3 is required to run Playscii.", file=sys.stderr) sys.exit(1) import platform 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 += ["."] # fix the working directory when running in a mac app if platform.system() == "Darwin" and hasattr(sys, "frozen"): os.chdir(os.path.abspath(os.path.dirname(sys.executable))) # app imports 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 from OpenGL import GL 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"): Image.ANTIALIAS = Image.LANCZOS # cache whether pdoc is available for help menu item pdoc_available = False try: pdoc_available = True except: pass # submodules - set here so cfg file can modify them all easily 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 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 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 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" 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" MAX_ONION_FRAMES = 3 class Application: # default window dimensions, may be updated during screen res detection window_width, window_height = 1280, 720 # if window gets extremely small it causes problems, enforce a minumum size # (UI becomes hard to use below 640 x 480, but allow it in case user is # doing something weird or reshuffling their desktop) min_window_width, min_window_height = 320, 240 # if True, use the older method of creating a test window to determine # desktop resolution - used to be needed on Linux, but maybe no longer. create_test_window = False fullscreen = False # framerate: uncapped if -1 framerate = 30 # fixed timestep for game physics update_rate = 30 # force to run even if we can't get an OpenGL 2.1 context run_if_opengl_incompatible = False # arbitrary size cap, but something bigger = probably a bad idea max_art_width, max_art_height = 9999, 9999 # use capslock as another ctrl key - SDL2 doesn't seem to respect OS setting capslock_is_ctrl = False bg_color = [0.2, 0.2, 0.2, 2] default_overlay_image_opacity = 0.25 show_bg_texture = True # if True, ignore camera loc saved in .psci files override_saved_camera = False # launch into art mode even if a game dir is specified via CLI always_launch_art_mode = False # show dev-only log messages 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." ) 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" # 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"], } # 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, ): self.init_success = False self.config_dir = config_dir # keep playscii.cfg lines in case we want to add some self.config_lines = open(self.config_dir + CONFIG_FILENAME).readlines() self.documents_dir = documents_dir self.cache_dir = cache_dir # last dir art was opened from self.last_art_dir = None # last dir file was imported from self.last_import_dir = None # class to use for temp thumbnail renderable self.thumbnail_renderable_class = TileRenderable # logger fed in from __main__ self.logger = logger self.last_time = 0 self.this_frame_start, self.last_frame_end = 0, 0 # number of updates (world, etc) and rendered frames this session self.updates, self.frames = 0, 0 self.timestep = (1 / self.update_rate) * 1000 # for FPS counter self.frame_time, self.fps = 0, 0 self.should_quit = False self.mouse_x, self.mouse_y = 0, 0 self.mouse_dx, self.mouse_dy = 0, 0 self.has_input_focus = self.has_mouse_focus = False self.inactive_layer_visibility = 1 self.version = get_version() # last edit came from keyboard or mouse, used by cursor control logic self.keyboard_editing = False # set ui None so other objects can check it None, eg load_art check # for its active art on later runs (audiolord too) 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": # 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.ext.init() except: try: sdl2.SDL_SetHint(sdl2.SDL_HINT_VIDEODRIVER, b"x11") sdl2.ext.init() except: sdl2.ext.init() winpos = sdl2.SDL_WINDOWPOS_UNDEFINED screen_width, screen_height = self.get_desktop_resolution() # make sure main window won't be too big for screen max_width = int(screen_width * 0.8) max_height = int(screen_height * 0.8) self.window_width = min(self.window_width, max_width) 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 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 ) # 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 ) 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 ) 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...") cpu = platform.processor() or platform.machine() 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: {} - {}".format(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") except: gl_ver = "[couldn't detect GL version]" self.log(" OpenGL detected: {}".format(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) ) except: 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: {}".format(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 {}found.".format(["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.run_if_opengl_incompatible: self.should_quit = True return # enforce GLSL version requirement try: gv = float(glsl_ver.split()[0]) if bool(glsl_ver) and gv <= 1.2: self.log("GLSL 1.30 or higher is required, " + self.compat_fail_message) if not self.run_if_opengl_incompatible: self.should_quit = True return except: # can't get a firm number out of reported GLSL version string :/ pass # detect max texture size 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: {} x {}".format( self.max_texture_size, self.max_texture_size ) ) self.log( " Detected screen resolution: {:.0f} x {:.0f}, window: {} x {}".format( screen_width, screen_height, self.window_width, self.window_height ) ) self.log("Detecting software environment...") self.log(" OS: {}".format(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") self.log(' Linux SDL2 "video driver": {}'.format(driver)) self.log(" Python: {} ({})".format(py_version, bitness)) module_versions = "PySDL2 {}, ".format(sdl2.__version__) module_versions += "numpy {}, ".format(numpy.__version__) module_versions += "PyOpenGL {}, ".format(OpenGL.__version__) module_versions += "appdirs {}, ".format(appdirs.__version__) module_versions += "PIL {}".format(PIL.__version__) self.log(" Modules: {}".format(module_versions)) sdl_version = "{}.{}.{} ".format( 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: {}.{}.{}".format( sdlmixer.SDL_MIXER_MAJOR_VERSION, sdlmixer.SDL_MIXER_MINOR_VERSION, sdlmixer.SDL_MIXER_PATCHLEVEL, ) self.log(" SDL: {}".format(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) # initialize audio self.al = AudioLord(self) self.set_icon() # SHADERLORD rules shader init/destroy, hot reload self.sl = ShaderLord(self) # separate cameras for edit vs game mode self.art_camera = Camera(self) self.camera = self.art_camera self.art_loaded_for_edit, self.edit_renderables = [], [] # raster image overlay self.overlay_renderable = None self.draw_overlay = False self.overlay_scale_type = OIS_WIDTH self.converter = None # set when an import is in progress self.importer = None # set when an exporter is chosen, remains so last_export can run self.exporter = None self.last_export_options = {} # dict of available importer/exporter modules self.converter_modules = {} # last art script run (remember for "run last") self.last_art_script = None self.game_mode = False self.gw = GameWorld(self) # if game dir specified, set it before we try to load any art if game_dir_to_load or autoplay_game: self.gw.set_game_dir(game_dir_to_load or autoplay_game, False) # autoplay = distribution mode, no editing if autoplay_game and not game_dir_to_load and self.gw.game_dir: self.can_edit = False # debug line renderable self.debug_line_renderable = DebugLineRenderable(self, None) # onion skin renderables self.onion_frames_visible = False self.onion_show_frames = MAX_ONION_FRAMES # store constant so input_handler etc can read it self.max_onion_frames = MAX_ONION_FRAMES self.onion_show_frames_behind = self.onion_show_frames_ahead = True self.onion_renderables_prev, self.onion_renderables_next = [], [] # lists of currently loaded character sets and palettes self.charsets, self.palettes = [], [] self.csl = CharacterSetLord(self) self.pl = PaletteLord(self) # set/create an active art self.load_art_for_edit(art_filename) self.fb = Framebuffer(self) # setting cursor None now makes for easier check in status bar drawing self.cursor, self.grid = None, None # separate grids for art vs game mode self.art_grid = None # forward-declare inputlord in case UI looks for it self.il = None # initialize UI with first art loaded active self.ui = UI(self, self.art_loaded_for_edit[0]) # textured background renderable self.bg_texture = UIBGTextureRenderable(self) # init onion skin for i in range(self.onion_show_frames): renderable = OnionTileRenderable(self, self.ui.active_art) self.onion_renderables_prev.append(renderable) for i in range(self.onion_show_frames): renderable = OnionTileRenderable(self, self.ui.active_art) self.onion_renderables_next.append(renderable) # set camera bounds based on art size self.camera.set_for_art(self.ui.active_art) self.update_window_title() self.cursor = Cursor(self) self.art_grid = ArtGrid(self, self.ui.active_art) self.grid = self.art_grid self.ui.set_active_layer(self.ui.active_art.active_layer) # INPUTLORD rules input handling and keybinds self.il = InputLord(self) # update UI (hovered elements) and cursor now that we have input self.ui.update() self.cursor.pre_first_update() self.pdoc_available = pdoc_available self.init_success = True self.log("Init done.") if self.can_edit: self.restore_session() # if art file was given in arguments, set it active if art_filename: self.ui.set_active_art_by_filename(art_filename) if (game_dir_to_load or autoplay_game) and self.gw.game_dir: # set initial game state if state_to_load: self.gw.load_game_state(state_to_load) else: self.gw.load_game_state() else: # 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: self.enter_game_mode() self.ui.set_game_edit_ui_visibility(False, False) 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 if not self.create_test_window: desktop = sdl2.video.SDL_DisplayMode() sdl2.SDL_GetDesktopDisplayMode(0, desktop) 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, ) 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) ) 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) sdl2.SDL_GetWindowPosition(self.window, wx, wy) wx, wy = int(wx.value), int(wy.value) mx, my = ctypes.c_int(0), ctypes.c_int(0) # "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 ) def set_icon(self): # TODO: this doesn't seem to work in Ubuntu, what am i missing? 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_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"): # ignore the importlib parts of the call stack, # not useful and always the same 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 {}! See console.".format(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): width, height = width or DEFAULT_WIDTH, height or DEFAULT_HEIGHT 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) art = None if not valid_filename: if autocreate: self.log("Creating new art {}".format(filename)) return self.new_art(filename) else: # 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: if a.filename == valid_filename: return a art = ArtFromDisk(valid_filename, self) # if loading failed, create new file if not art or not art.valid: return self.new_art(valid_filename) # 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) renderable = TileRenderable(self, art) self.edit_renderables.insert(0, renderable) 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) 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 art not in self.art_loaded_for_edit: return self.art_loaded_for_edit.remove(art) for r in art.renderables: if r in self.edit_renderables: self.edit_renderables.remove(r) if art is self.ui.active_art: self.ui.active_art = None self.log("Unloaded {}".format(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() 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 = [] # build list of dirs to check, by priority: # gamedir/subdir if it exists, then ./subdir, then ./ if self.gw.game_dir is not None: game_dir = self.gw.game_dir if subdir: game_dir += subdir if os.path.exists(game_dir): dirnames.append(game_dir) if subdir is not None: dirnames.append(subdir) if include_base: dirnames.append("") # add duplicate set of dirs in user documents path doc_dirs = [] for dirname in dirnames: # dir might already have documents path in it, add as-is if so if dirname.startswith(self.documents_dir) and os.path.exists(dirname): doc_dirs.append(dirname) continue doc_dir = self.documents_dir + dirname if os.path.exists(doc_dir): 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 == "": return None dirnames = self.get_dirnames(subdir) # build list of filenames from each dir, first w/ extension then w/o filenames = [] # extensions: accept list or single item, # list with one empty string if None passed if extensions is None or len(extensions) == 0: extensions = [""] elif type(extensions) is not list: extensions = [extensions] for dirname in dirnames: for ext in extensions: f = "{}{}".format(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 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 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": continue try: if basename in self.converter_modules: m = importlib.reload(self.converter_modules[basename]) else: m = importlib.import_module("formats.{}".format(basename)) self.converter_modules[basename] = m except Exception as e: self.log_import_exception(e, basename) 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) if v in classes: continue 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? base_charset_to_load = os.path.basename(charset_to_load) base_charset_to_load = os.path.splitext(base_charset_to_load)[0] for charset in self.charsets: if charset.base_filename == base_charset_to_load: return charset new_charset = CharacterSet(self, charset_to_load, log) if new_charset.init_success: self.charsets.append(new_charset) return new_charset 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] for palette in self.palettes: if palette.base_filename == base_palette_to_load: return palette new_palette = Palette(self, palette_to_load, log) if new_palette.init_success: self.palettes.append(new_palette) return new_palette 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 = "{} - {}".format(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: # if edit UI is up, show last loaded state if self.ui.game_menu_bar.visible: title = self.gw.last_state_loaded # if not, show user-friendly game name else: title = self.gw.game_title else: title = "Game Mode" self.set_window_title(title) return if not self.ui or not self.ui.active_art: self.set_window_title() return # show message if converting if self.converter: title = self.img_convert_message % self.converter.image_filename self.set_window_title(title) return # display current active document's name and info filename = self.ui.active_art.filename if filename and os.path.exists(filename): full_filename = os.path.abspath(filename) else: full_filename = filename if self.ui.active_art.unsaved_changes: 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 # tell FB, camera, and UI that view aspect has changed 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 if self.fullscreen: flags = sdl2.SDL_WINDOW_FULLSCREEN_DESKTOP 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_{}.png".format(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 ) pixel_bytes = pixels.flatten().tobytes() img = Image.frombytes(mode="RGBA", size=(w, h), data=pixel_bytes) img = img.transpose(Image.FLIP_TOP_BOTTOM) img.save("{}{}".format(self.documents_dir + SCREENSHOT_DIR, output_filename)) self.log("Saved screenshot {}".format(output_filename)) def enter_game_mode(self): self.game_mode = True self.camera = self.gw.camera self.grid = self.gw.grid # 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 = 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.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: self.this_frame_start = self.get_elapsed_time() self.update() self.render() self.last_frame_end = self.get_elapsed_time() self.frames += 1 self.sl.check_hot_reload() self.csl.check_hot_reload() self.pl.check_hot_reload() # determine FPS # alpha: lower = smoother alpha = 0.05 dt = self.get_elapsed_time() - self.this_frame_start self.frame_time = alpha * dt + (1 - alpha) * self.frame_time self.fps = 1000 / self.frame_time # delay to maintain framerate, if uncapped if self.framerate != -1: 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)) sdl2.timer.SDL_Delay(int(delay)) return 1 def update(self): # update whether app has mouse + input (keybaord) focus flags = ctypes.c_uint(0) flags = sdl2.SDL_GetWindowFlags(self.window) self.has_input_focus = flags & sdl2.SDL_WINDOW_INPUT_FOCUS self.has_mouse_focus = flags & sdl2.SDL_WINDOW_MOUSE_FOCUS # start-of-frame stuff if self.game_mode: self.gw.frame_begin() else: # set all arts to "not updated" for art in self.art_loaded_for_edit: art.updated_this_tick = False # handle input - once per frame self.il.handle_input() # update game world & anything else that should happen on fixed timestep # avoid too many updates if eg machine straight up hangs if self.get_elapsed_time() - self.last_time > 1000: self.last_time = self.get_elapsed_time() updates = (self.get_elapsed_time() - self.last_time) / self.timestep for i in range(int(updates)): if self.game_mode: self.gw.pre_update() self.gw.update() self.gw.post_update() 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: self.converter.update() # game world has its own once-a-frame updates, eg art/renderables if self.game_mode: self.gw.frame_update() 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 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: self.grid.update() self.cursor.end_update() 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: {}".format(self.ui.active_art.active_frame), ""] debug.append("onion_renderables_prev:") def get_onion_info(i, r): visible = "VISIBLE" if r.visible else "" return "{}: {} frame {} {}".format( 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(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") r = SpriteRenderable(self, image_filename, img) r.alpha = self.default_overlay_image_opacity except Exception: for line in traceback.format_exc().split("\n")[3:]: if line.strip(): self.log(line.rstrip()) return self.log("Using {} as overlay image.".format(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) bg_color = self.gw.bg_color if self.game_mode else self.bg_color GL.glClearColor(*bg_color) GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT) if self.game_mode: self.gw.render() else: for renderable in self.edit_renderables: renderable.update() if self.show_bg_texture: self.bg_texture.render() if self.converter: self.converter.preview_sprite.render() for r in self.edit_renderables: r.render() # self.debug_onion_frames() if self.onion_frames_visible: # draw "nearest" frames first i = 0 while i < self.onion_show_frames: if self.onion_show_frames_behind: self.onion_renderables_prev[i].render() if self.onion_show_frames_ahead: self.onion_renderables_next[i].render() i += 1 # draw selection grid, then selection, then cursor 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 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: self.overlay_renderable.render() # draw framebuffer to screen GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0) self.fb.render() if self.ui.visible: 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: 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 += "{} = {}\n".format(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) 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): return # more recent arts should open later filenames = open(session_filename).readlines() 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") 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.close() def quit(self): if self.init_success: self.save_persistent_config() self.save_session() for r in self.edit_renderables: r.destroy() self.gw.destroy() self.fb.destroy() self.ui.destroy() for charset in self.charsets: charset.texture.destroy() for palette in self.palettes: palette.texture.destroy() self.sl.destroy() if self.al: self.al.destroy() sdl2.SDL_GL_DeleteContext(self.context) sdl2.SDL_DestroyWindow(self.window) sdl2.SDL_Quit() # write to config file 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") 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" else: editor_bin = os.environ.get("EDITOR", None) if not editor_bin: return cmd = '{} "{}"'.format(editor_bin, cfg_path) os.system(cmd) # update resident cfg file lines, which will be saved out on exit self.config_lines = open(cfg_path).readlines() # 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://{}/{}".format(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: import pdoc except: self.log("pdoc module needed for documentation generation not found.") return 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.write(html) docfile.close() self.log("Documentation generated successfully.") # open ToC page self.open_local_url(AUTOGEN_DOCS_PATH + AUTOGEN_DOC_TOC_PAGE) 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 buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) 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) + "/" 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_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 # 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("~") if os.path.exists(documents_dir + DOCUMENTS_SUBDIR): documents_dir += DOCUMENTS_SUBDIR # add Playscii/ to documents path documents_dir += "/{}/".format(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, ]: 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 return config_dir, documents_dir, cache_dir def get_version(): return open(VERSION_FILENAME).readlines()[0].strip() 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) def log(self, new_line): self.log_file.write("{}\n".format(new_line)) self.lines.append(str(new_line)) print(new_line) def close(self): self.log_file.close() def get_app(): "creates and returns the Application instance" # get paths for config file, later to be passed into Application config_dir, documents_dir, cache_dir = get_paths() # start logger even before Application has initialized so we can write to it # startup message: application and version # logger = Logger(config_dir) logger.log("{} v{}".format(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): ap = open(AUTOPLAY_GAME_FILENAME).readlines() if len(ap) > 0: autoplay_game = ap[0].strip() # 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 {}...".format(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): cfg_line = cfg_line.strip() try: exec(cfg_line) 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]" for el in error_lines: if "Error" in el: error = el break logger.log(" Removing line {} with {}".format(i, error)) new_cfg = open(cfg_filename, "w") new_cfg.writelines(new_cfg_lines) new_cfg.close() 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.writelines(default_data) new_cfg.close() exec("".join(default_data)) logger.log("Created new config file {}".format(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": 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": 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, ) return app if __name__ == "__main__": app = get_app() error = app.main_loop() app.quit() app.logger.close() sys.exit(error)