playscii/playscii.py

1341 lines
53 KiB
Python
Executable file

#!/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)