playscii/ui_console.py

538 lines
20 KiB
Python

import os
import sdl2
from math import ceil
from ui_element import UIElement
from art import UV_FLIPY
from key_shifts import SHIFT_MAP
from image_convert import ImageConverter
from palette import PaletteFromFile
from image_export import export_still_image, export_animation
from PIL import Image
# imports for console execution namespace - be careful!
from OpenGL import GL
CONSOLE_HISTORY_FILENAME = 'console_history'
class ConsoleCommand:
"parent class for console commands"
description = '[Enter a description for this command!]'
def execute(console, args):
return 'Test command executed.'
class QuitCommand(ConsoleCommand):
description = 'Quit Playscii.'
def execute(console, args):
console.ui.app.should_quit = True
class SaveCommand(ConsoleCommand):
description = 'Save active art, under new filename if given.'
def execute(console, args):
# save currently active file
art = console.ui.active_art
# set new filename if given
if len(args) > 0:
old_filename = art.filename
art.set_filename(' '.join(args))
art.save_to_file()
console.ui.app.load_art_for_edit(old_filename)
console.ui.set_active_art_by_filename(art.filename)
else:
art.save_to_file()
console.ui.app.update_window_title()
class OpenCommand(ConsoleCommand):
description = 'Open art with given filename.'
def execute(console, args):
if len(args) == 0:
return 'Usage: open [art filename]'
filename = ' '.join(args)
console.ui.app.load_art_for_edit(filename)
class RevertArtCommand(ConsoleCommand):
description = 'Revert active art to last saved version.'
def execute(console, args):
console.ui.app.revert_active_art()
class LoadPaletteCommand(ConsoleCommand):
description = 'Set the given color palette as active.'
def execute(console, args):
if len(args) == 0:
return 'Usage: pal [palette filename]'
filename = ' '.join(args)
# load AND set
palette = console.ui.app.load_palette(filename)
console.ui.active_art.set_palette(palette)
console.ui.popup.set_active_palette(palette)
class LoadCharSetCommand(ConsoleCommand):
description = 'Set the given character set as active.'
def execute(console, args):
if len(args) == 0:
return 'Usage: char [character set filename]'
filename = ' '.join(args)
charset = console.ui.app.load_charset(filename)
console.ui.active_art.set_charset(charset)
console.ui.popup.set_active_charset(charset)
class ImageExportCommand(ConsoleCommand):
description = 'Export active art as PNG image.'
def execute(console, args):
export_still_image(console.ui.app, console.ui.active_art)
class AnimExportCommand(ConsoleCommand):
description = 'Export active art as animated GIF image.'
def execute(console, args):
export_animation(console.ui.app, console.ui.active_art)
class ConvertImageCommand(ConsoleCommand):
description = 'Convert given bitmap image to current character set + color palette.'
def execute(console, args):
if len(args) == 0:
return 'Usage: conv [image filename]'
image_filename = ' '.join(args)
ImageConverter(console.ui.app, image_filename, console.ui.active_art)
console.ui.app.update_window_title()
class OverlayImageCommand(ConsoleCommand):
description = 'Draw given bitmap image over active art document.'
def execute(console, args):
if len(args) == 0:
return 'Usage: img [image filename]'
image_filename = ' '.join(args)
console.ui.app.set_overlay_image(image_filename)
class ImportCommand(ConsoleCommand):
description = 'Import file using an ArtImport class'
def execute(console, args):
if len(args) < 2:
return 'Usage: imp [ArtImporter class name] [filename]'
importers = console.ui.app.get_importers()
importer_classname, filename = args[0], args[1]
importer_class = None
for c in importers:
if c.__name__ == importer_classname:
importer_class = c
if not importer_class:
console.ui.app.log("Couldn't find importer class %s" % importer_classname)
if not os.path.exists(filename):
console.ui.app.log("Couldn't find file %s" % filename)
importer = importer_class(console.ui.app, filename)
class ExportCommand(ConsoleCommand):
description = 'Export current art using an ArtExport class'
def execute(console, args):
if len(args) < 2:
return 'Usage: exp [ArtExporter class name] [filename]'
exporters = console.ui.app.get_exporters()
exporter_classname, filename = args[0], args[1]
exporter_class = None
for c in exporters:
if c.__name__ == exporter_classname:
exporter_class = c
if not exporter_class:
console.ui.app.log("Couldn't find exporter class %s" % exporter_classname)
exporter = exporter_class(console.ui.app, filename)
class PaletteFromImageCommand(ConsoleCommand):
description = 'Convert given image into a palette file.'
def execute(console, args):
if len(args) == 0:
return 'Usage: getpal [image filename]'
src_filename = ' '.join(args)
new_pal = PaletteFromFile(console.ui.app, src_filename, src_filename)
if not new_pal.init_success:
return
#console.ui.app.load_palette(new_pal.filename)
console.ui.app.palettes.append(new_pal)
console.ui.active_art.set_palette(new_pal)
console.ui.popup.set_active_palette(new_pal)
class SetGameDirCommand(ConsoleCommand):
description = 'Load game from the given folder.'
def execute(console, args):
if len(args) == 0:
return 'Usage: setgame [game dir name]'
game_dir_name = ' '.join(args)
console.ui.app.gw.set_game_dir(game_dir_name, True)
class LoadGameStateCommand(ConsoleCommand):
description = 'Load the given game state save file.'
def execute(console, args):
if len(args) == 0:
return 'Usage: game [game state filename]'
gs_name = ' '.join(args)
console.ui.app.gw.load_game_state(gs_name)
class SaveGameStateCommand(ConsoleCommand):
description = 'Save the current game state as the given filename.'
def execute(console, args):
"Usage: savegame [game state filename]"
gs_name = ' '.join(args)
console.ui.app.gw.save_to_file(gs_name)
class SpawnObjectCommand(ConsoleCommand):
description = 'Spawn an object of the given class name.'
def execute(console, args):
if len(args) == 0:
return 'Usage: spawn [class name]'
class_name = ' '.join(args)
console.ui.app.gw.spawn_object_of_class(class_name)
class CommandListCommand(ConsoleCommand):
description = 'Show the list of console commands.'
def execute(console, args):
# TODO: print a command with usage if available
console.ui.app.log('Commands:')
# alphabetize command list
command_list = list(commands.keys())
command_list.sort()
for command in command_list:
desc = commands[command].description
console.ui.app.log(' %s - %s' % (command, desc))
class RunArtScriptCommand(ConsoleCommand):
description = 'Run art script with given filename on active art.'
def execute(console, args):
if len(args) == 0:
return 'Usage: src [art script filename]'
filename = ' '.join(args)
console.ui.active_art.run_script(filename)
class RunEveryArtScriptCommand(ConsoleCommand):
description = 'Run art script with given filename on active art at given rate.'
def execute(console, args):
if len(args) < 2:
return 'Usage: srcev [rate] [art script filename]'
rate = float(args[0])
filename = ' '.join(args[1:])
console.ui.active_art.run_script_every(filename, rate)
# hide so user can immediately see what script is doing
console.hide()
class StopArtScriptsCommand(ConsoleCommand):
description = 'Stop all actively running art scripts.'
def execute(console, args):
console.ui.active_art.stop_all_scripts()
# map strings to command classes for ConsoleUI.parse
commands = {
'exit': QuitCommand,
'quit': QuitCommand,
'save': SaveCommand,
'open': OpenCommand,
'char': LoadCharSetCommand,
'pal': LoadPaletteCommand,
'imgexp': ImageExportCommand,
'animexport': AnimExportCommand,
'conv': ConvertImageCommand,
'getpal': PaletteFromImageCommand,
'setgame': SetGameDirCommand,
'game': LoadGameStateCommand,
'savegame': SaveGameStateCommand,
'spawn': SpawnObjectCommand,
'help': CommandListCommand,
'scr': RunArtScriptCommand,
'screv': RunEveryArtScriptCommand,
'scrstop': StopArtScriptsCommand,
'revert': RevertArtCommand,
'img': OverlayImageCommand,
'imp': ImportCommand,
'exp': ExportCommand
}
class ConsoleUI(UIElement):
visible = False
snap_top = True
snap_left = True
# how far down the screen the console reaches when visible
height_screen_pct = 0.75
# how long (seconds) to shift/fade into view when invoked
show_anim_time = 0.75
bg_alpha = 0.75
prompt = '>'
# _ ish char
bottom_line_char_index = 76
right_margin = 3
# transient, but must be set here b/c UIElement.init calls reset_art
current_line = ''
game_mode_visible = True
all_modes_visible = True
def __init__(self, ui):
self.bg_color_index = ui.colors.darkgrey
self.highlight_color = 8 # yellow
UIElement.__init__(self, ui)
# state stuff for console move/fade
self.alpha = 0
self.target_alpha = 0
self.target_y = 2
# start off top of screen
self.renderable.y = self.y = 2
# user input and log
self.last_lines = []
self.history_filename = self.ui.app.config_dir + CONSOLE_HISTORY_FILENAME
if os.path.exists(self.history_filename):
self.history_file = open(self.history_filename, 'r')
try:
self.command_history = self.history_file.readlines()
except:
self.command_history = []
self.history_file = open(self.history_filename, 'a')
else:
self.history_file = open(self.history_filename, 'w+')
self.command_history = []
self.history_index = 0
# junk data in last user line so it changes on first update
self.last_user_line = 'test'
# max line length = width of console minus prompt + _
self.max_line_length = int(self.art.width) - self.right_margin
def reset_art(self):
self.width = ceil(self.ui.width_tiles * self.ui.scale)
# % of screen must take aspect into account
inv_aspect = self.ui.app.window_height / self.ui.app.window_width
self.height = int(self.ui.height_tiles * self.height_screen_pct * inv_aspect * self.ui.scale)
# dim background
self.renderable.bg_alpha = self.bg_alpha
# must resize here, as window width will vary
self.art.resize(self.width, self.height)
self.max_line_length = int(self.width) - self.right_margin
self.text_color = self.ui.palette.lightest_index
self.clear()
# truncate current user line if it's too long for new width
self.current_line = self.current_line[:self.max_line_length]
#self.update_user_line()
# empty log lines so they refresh from app
self.last_user_line = 'XXtestXX'
self.last_lines = []
def toggle(self):
if self.visible:
self.hide()
else:
self.show()
def show(self):
self.visible = True
self.target_alpha = 1
self.target_y = 1
self.ui.menu_bar.visible = False
self.ui.pulldown.visible = False
def hide(self):
self.target_alpha = 0
self.target_y = 2
if self.ui.app.can_edit:
self.ui.menu_bar.visible = True
def update_loc(self):
# TODO: this lerp is super awful, simpler way based on dt?
# TODO: use self.show_anim_time instead of this garbage!
speed = 0.25
if self.y > self.target_y:
self.y -= speed
elif self.y < self.target_y:
self.y += speed
if abs(self.y - self.target_y) < speed:
self.y = self.target_y
if self.alpha > self.target_alpha:
self.alpha -= speed / 2
elif self.alpha < self.target_alpha:
self.alpha += speed / 2
if abs(self.alpha - self.target_alpha) < speed:
self.alpha = self.target_alpha
if self.alpha == 0:
self.visible = False
self.renderable.y = self.y
self.renderable.alpha = self.alpha
def clear(self):
self.art.clear_frame_layer(0, 0, self.bg_color_index)
# line -1 is always a line of ____________
for x in range(self.width):
self.art.set_tile_at(0, 0, x, -1, self.bottom_line_char_index, self.text_color, None, UV_FLIPY)
def update_user_line(self):
"draw current user input on second to last line, with >_ prompt"
# clear entire user line first
self.art.write_string(0, 0, 0, -2, ' ' * self.width, self.text_color)
self.art.write_string(0, 0, 0, -2, '%s ' % self.prompt, self.text_color)
# if first item of line is a valid command, change its color
items = self.current_line.split()
if len(items) > 0 and items[0] in commands:
self.art.write_string(0, 0, 2, -2, items[0], self.highlight_color)
offset = 2 + len(items[0]) + 1
args = ' '.join(items[1:])
self.art.write_string(0, 0, offset, -2, args, self.text_color)
else:
self.art.write_string(0, 0, 2, -2, self.current_line, self.text_color)
# draw underscore for caret at end of input string
x = len(self.prompt) + len(self.current_line) + 1
i = self.ui.charset.get_char_index('_')
self.art.set_char_index_at(0, 0, x, -2, i)
def update_log_lines(self):
"update art from log lines"
log_index = -1
for y in range(self.height - 3, -1, -1):
try:
line = self.ui.app.logger.lines[log_index]
except IndexError:
break
# trim line to width of console
if len(line) >= self.max_line_length:
line = line[:self.max_line_length]
self.art.write_string(0, 0, 1, y, line, self.text_color)
log_index -= 1
def update(self):
"update our Art with the current console log lines + user input"
self.update_loc()
if not self.visible:
return
# check for various early out scenarios, updating all chars every frame
# gets expensive
user_input_changed = self.last_user_line != self.current_line
log_changed = self.last_lines != self.ui.app.logger.lines
# remember log & user lines, bail early next update if no change
self.last_lines = self.ui.app.logger.lines[:]
self.last_user_line = self.current_line
if not user_input_changed and not log_changed:
return
# if log lines changed, clear all tiles to shift in new text
if log_changed:
self.clear()
self.update_log_lines()
# if user pressed enter on a blank line, user_input_changed will
# be False but we should still update
self.update_user_line()
# update user line independently of log, it changes at a different rate
if user_input_changed:
self.update_user_line()
def visit_command_history(self, index):
if len(self.command_history) == 0:
return
self.history_index = index
self.history_index %= len(self.command_history)
self.current_line = self.command_history[self.history_index].strip()
def handle_input(self, key, shift_pressed, alt_pressed, ctrl_pressed):
"handles a key from Application.input"
keystr = sdl2.SDL_GetKeyName(key).decode()
# TODO: get console bound key from InputLord, detect that instead of
# hard-coded backquote
if keystr == '`':
self.toggle()
return
elif keystr == 'Return':
line = '%s %s' % (self.prompt, self.current_line)
self.ui.app.log(line)
# if command is same as last, don't repeat it
if len(self.command_history) == 0 or (len(self.command_history) > 0 and self.current_line != self.command_history[-1]):
# don't add blank lines to history
if self.current_line.strip():
self.command_history.append(self.current_line)
self.history_file.write(self.current_line + '\n')
self.parse(self.current_line)
self.current_line = ''
self.history_index = 0
elif keystr == 'Tab':
# TODO: autocomplete (commands, filenames)
pass
elif keystr == 'Up':
# page back through command history
self.visit_command_history(self.history_index - 1)
elif keystr == 'Down':
# page forward through command history
self.visit_command_history(self.history_index + 1)
elif keystr == 'Backspace' and len(self.current_line) > 0:
# alt-backspace: delete to last delimiter, eg periods
if alt_pressed:
# "index to delete to"
delete_index = -1
for delimiter in delimiters:
this_delimiter_index = self.current_line.rfind(delimiter)
if this_delimiter_index > delete_index:
delete_index = this_delimiter_index
if delete_index > -1:
self.current_line = self.current_line[:delete_index]
else:
self.current_line = ''
# user is bailing on whatever they were typing,
# reset position in cmd history
self.history_index = 0
else:
self.current_line = self.current_line[:-1]
if len(self.current_line) == 0:
# same as above: reset position in cmd history
self.history_index = 0
elif keystr == 'Space':
keystr = ' '
# ignore any other non-character keys
if len(keystr) > 1:
return
if keystr.isalpha() and not shift_pressed:
keystr = keystr.lower()
elif not keystr.isalpha() and shift_pressed:
keystr = SHIFT_MAP.get(keystr, '')
if len(self.current_line) < self.max_line_length:
self.current_line += keystr
def parse(self, line):
# is line in a list of know commands? if so, handle it.
items = line.split()
output = None
if len(items) == 0:
pass
elif items[0] in commands:
cmd = commands[items[0]]
output = cmd.execute(self, items[1:])
else:
# if not, try python eval, give useful error if it fails
try:
# set some locals for easy access from eval
ui = self.ui
app = ui.app
camera = app.camera
art = ui.active_art
player = app.gw.player
sel = None if len(app.gw.selected_objects) == 0 else app.gw.selected_objects[0]
world = app.gw
hud = app.gw.hud
# special handling of assignment statements, eg x = 3:
# detect strings that pattern-match, send them to exec(),
# send all other strings to eval()
eq_index = line.find('=')
is_assignment = eq_index != -1 and line[eq_index+1] != '='
if is_assignment:
exec(line)
else:
output = str(eval(line))
except Exception as e:
# try to output useful error text
output = '%s: %s' % (e.__class__.__name__, str(e))
# commands CAN return None, so only log if there's something
if output and output != 'None':
self.ui.app.log(output)
def destroy(self):
self.history_file.close()
# delimiters - alt-backspace deletes to most recent one of these
delimiters = [' ', '.', ')', ']', ',', '_']