playscii/playscii.py

1228 lines
53 KiB
Python
Executable file

#!/usr/bin/env python3
# coding=utf-8
from __future__ import print_function
import sys, os.path
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, time, hashlib, importlib, traceback
import webbrowser
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
# 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:
import pdoc
pdoc_available = True
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_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 cursor import Cursor
from grid import ArtGrid
from input_handler import InputLord
from ui_file_chooser_dialog import THUMBNAIL_CACHE_DIR
# 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
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: %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')
except:
gl_ver = "[couldn't detect GL version]"
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))
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: %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])
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: %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')
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)
# 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 not 'importlib' in line and \
not 'in _import_all' in line and \
not '_bootstrap._gcd_import' in line:
self.log(line.rstrip())
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):
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 %s' % 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 not art 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 %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()
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 not type(extensions) is list:
extensions = [extensions]
for dirname in dirnames:
for ext in extensions:
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
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:
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.%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:
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 = '%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:
# 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_%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)
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('%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
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 \
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:
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: %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):
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 as e:
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.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 \
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:
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 += '%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)
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 = '%s "%s"' % (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://%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:
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 += '/%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]:
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('%s\n' % 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('%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):
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 %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):
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 %s with %s' % (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 %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':
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)