playscii/ui_console.py

596 lines
21 KiB
Python

import os
from math import ceil
import sdl2
# imports for console execution namespace - be careful!
from art import UV_FLIPY
from image_convert import ImageConverter
from image_export import export_animation, export_still_image
from key_shifts import SHIFT_MAP
from palette import PaletteFromFile
from ui_element import UIElement
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 {}".format(importer_classname)
)
if not os.path.exists(filename):
console.ui.app.log("Couldn't find file {}".format(filename))
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 {}".format(exporter_classname)
)
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(" {} - {}".format(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)
try:
self.command_history = self.history_file.readlines()
except Exception:
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, "{} ".format(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 = "{} {}".format(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 = "{}: {}".format(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 = [" ", ".", ")", "]", ",", "_"]