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(f"Couldn't find importer class {importer_classname}") if not os.path.exists(filename): console.ui.app.log(f"Couldn't find file {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(f"Couldn't find exporter class {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(f" {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, f"{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 = f"{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 = f"{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 = [" ", ".", ")", "]", ",", "_"]