538 lines
20 KiB
Python
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 = [' ', '.', ')', ']', ',', '_']
|