From 5979e38e5763fed7d59f22d1f185d3807855140c Mon Sep 17 00:00:00 2001 From: Mansur Isaev <737dab2f169a@mail.ru> Date: Sun, 9 Oct 2022 08:23:19 +0400 Subject: [PATCH] Initial commit --- .gitattributes | 7 ++ .gitignore | 2 + LICENSE.md | 21 ++++ README.md | 68 +++++++++++++ icons/console_container.svg | 3 + plugin.cfg | 7 ++ plugin.gd | 23 +++++ scripts/console.gd | 166 +++++++++++++++++++++++++++++++ scripts/console_command.gd | 186 +++++++++++++++++++++++++++++++++++ scripts/console_container.gd | 101 +++++++++++++++++++ 10 files changed, 584 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 icons/console_container.svg create mode 100644 plugin.cfg create mode 100644 plugin.gd create mode 100644 scripts/console.gd create mode 100644 scripts/console_command.gd create mode 100644 scripts/console_container.gd diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..85718c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf + +/.gitattributes export-ignore +/.gitignore export-ignore +/README.md export-ignore +/LICENSE.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d2ca55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Godot auto generated files +*.import diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c98de02 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2020-2022 Mansur Isaev and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da1e0b --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Godot-Console + +Simple in-game console for Godot 4.0. + +![](https://user-images.githubusercontent.com/8208165/144989905-6d3eb45d-26e7-4acd-9a53-c31d7e49c400.png) + +# Features + +- Installed as plugin. +- The Console is Singleton. +- History of entered commands. +- Autocomplete commands. +- Static typing. + +# Installation: + +1. Clone this repository to `addons` folder. +2. Enabled `Godot Console` in Plugins. +3. Add `ConsoleContainer` node to the scene. +4. Profit. + +# Usage: + +## Create console command: + +```gdscript +# player.gd +func teleport(x: float, y: float) -> void: + self.position = Vector2(x, y) + +func _ready() -> void: + Console.create_command("tp", self.teleport, "Teleport the player.") +``` + +## Static typing: + +With static typing, Console will try to cast arguments to a supported type. +```gdscript +# Arguments is float. +func teleport(x: float, y: float) -> void: + self.position = Vector2(x, y) +``` + +## Dynamic typing: + +With dynamic typing, Console will NOT cast arguments to type, and arguments will be a String. +```gdscript +# Arguments is Strings. +func teleport(x, y): + # Convert arguments to float. + self.position = Vector2(x.to_float(), y.to_float()) +``` + +## Optional return string for print result to the console. + +```gdscript +func add_money(value: int) -> String: + self.money += value + # Prints: Player money:42 + return "Player money:%d" % money +``` + +# License + +Copyright (c) 2020-2022 Mansur Isaev and contributors + +Unless otherwise specified, files in this repository are licensed under the +MIT license. See [LICENSE.md](LICENSE.md) for more information. diff --git a/icons/console_container.svg b/icons/console_container.svg new file mode 100644 index 0000000..d821c3c --- /dev/null +++ b/icons/console_container.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugin.cfg b/plugin.cfg new file mode 100644 index 0000000..9c90e6b --- /dev/null +++ b/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot Console" +description="in-Game Console." +author="Mansur Isaev" +version="1.0" +script="plugin.gd" diff --git a/plugin.gd b/plugin.gd new file mode 100644 index 0000000..22a65ee --- /dev/null +++ b/plugin.gd @@ -0,0 +1,23 @@ +# Copyright (c) 2020-2022 Mansur Isaev and contributors - MIT License +# See `LICENSE.md` included in the source distribution for details. + +@tool +extends EditorPlugin + + +const AUTOLOAD_NAME = "Console" +const AUTOLOAD_PATH = "res://addons/godot-console/scripts/console.gd" + +const CONSOLE_CONTAINER = "ConsoleContainer" +const CONSOLE_CONTAINER_SCRIPT = "res://addons/godot-console/scripts/console_container.gd" +const CONSOLE_CONTAINER_ICON = "res://addons/godot-console/icons/console_container.svg" + + +func _enter_tree() -> void: + add_autoload_singleton(AUTOLOAD_NAME, AUTOLOAD_PATH) + add_custom_type(CONSOLE_CONTAINER, "VBoxContainer", load(CONSOLE_CONTAINER_SCRIPT), load(CONSOLE_CONTAINER_ICON)) + + +func _exit_tree() -> void: + remove_custom_type(CONSOLE_CONTAINER) + remove_autoload_singleton(AUTOLOAD_NAME) diff --git a/scripts/console.gd b/scripts/console.gd new file mode 100644 index 0000000..c69f82d --- /dev/null +++ b/scripts/console.gd @@ -0,0 +1,166 @@ +# Copyright (c) 2020-2022 Mansur Isaev and contributors - MIT License +# See `LICENSE.md` included in the source distribution for details. + +## ConsoleNode class. +## +## By default used as a Singleton. To create a new console command, use [method create_command]. +class_name ConsoleNode +extends Node + +## Emitted when the console prints a string. +signal printed_line(string: String) +## Emitted when the console history is cleared. +signal cleared() + + +var _command_map : Dictionary +var _command_list : PackedStringArray + +var _history : PackedStringArray +var _history_index : int + + +func _init() -> void: + # Built-in methods: + create_command("target_fps", Engine.set_target_fps, "The desired frames per second. A value of 0 means no limit.") + create_command("physics_ticks", Engine.set_physics_ticks_per_second, "Set physic tick per second.") + # Custom methods: + create_command("clear", clear, "Clear the console history.") + create_command("help", _command_help, "Show all console command.") + create_command("version", _command_version, "Show engine version.") + create_command("test", _command_test, "Test console output.") + create_command("quit", _command_quit, "Quit the application.") + +## Return [param true] if the console has a command. +func has_command(command: String) -> bool: + return _command_map.has(command) + +## Return [param true] if command name is valid. +func is_valid_name(command: String) -> bool: + return command.is_valid_identifier() + +## Add a command to the console. +## Can be used to directly add a custom command. +func add_command(command: ConsoleCommand) -> void: + assert(is_instance_valid(command) and command.is_valid(), "Invalid command.") + assert(not has_command(command.get_name()), "Has command.") + + if is_instance_valid(command) and command.is_valid() and not has_command(command.get_name()): + _command_map[command.get_name()] = command + _command_list.clear() # Clear for lazy initialization. + +## Remove a command from the console. +func remove_command(command: String) -> bool: + if _command_map.erase(command): + _command_list.clear() + return true + + return false + +## Return command. +func get_command(command: String) -> ConsoleCommand: + return _command_map[command] as ConsoleCommand + +## Create and add a new console command. +func create_command(command: String, callable: Callable, description := "") -> void: + assert(not has_command(command), "Has command.") + assert(is_valid_name(command), "Invalid command name.") + assert(callable.is_valid(), "Invalid callable.") + + if not has_command(command) and is_valid_name(command) and callable.is_valid(): + self.add_command(ConsoleCommand.new(command, callable, description)) + +## Print string to the console. +func print_line(string: String) -> void: + printed_line.emit(string + "\n") + +## Execute command. First word must be a command name, other is arguments. +func execute(string: String) -> void: + var args : PackedStringArray = string.split(" ", false) + if args.is_empty(): + return + + _history.push_back(string) + _history_index = _history.size() + + print_line("[color=GRAY]> " + string + "[/color]") + + if not has_command(args[0]): + return print_line("[color=RED]Command \"" + string + "\" not found.[/color]") + + var command := get_command(args[0]) + + assert(is_instance_valid(command), "Invalid ConsoleCommand.") + if not is_instance_valid(command): + return + + args.remove_at(0) # Remove name from arguments. + + var result : String = command.execute(args) + if result: + print_line(result) + +## Return the previously entered command. +func get_prev_command() -> String: + _history_index = wrapi(_history_index - 1, 0, _history.size()) + return "" if _history.is_empty() else _history[_history_index] + +## Return the next entered command. +func get_next_command() -> String: + _history_index = wrapi(_history_index + 1, 0, _history.size()) + return "" if _history.is_empty() else _history[_history_index] + +## Return a list of all commands. +func get_command_list() -> PackedStringArray: + if _command_list.is_empty(): # Lazy initialization. + _command_list = _command_map.keys() + _command_list.sort() + + return _command_list + +## Return autocomplete command. +func autocomplete_command(string: String) -> String: + if string.is_empty(): + return string + + for command in get_command_list(): + if command.begins_with(string): + return command + + return string + +## Return a list of autocomplete commands. +func autocomplete_list(string: String) -> PackedStringArray: + var list := PackedStringArray() + if string.is_empty(): + return list + + for command in get_command_list(): + if command.begins_with(string): + list.push_back(command) + + return list + +## Clear the console history. +func clear() -> void: + _history.clear() + _history_index = 0 + + cleared.emit() + + +func _command_help() -> void: + for i in get_command_list(): + print_line(i + "- " + get_command(i).get_description()) + + +func _command_version() -> String: + return "Godot Engine {major}.{minor}.{patch}".format(Engine.get_version_info()) + + +func _command_test() -> String: + return "The quick brown fox jumps over the lazy dog." + + +func _command_quit() -> void: + get_tree().quit() diff --git a/scripts/console_command.gd b/scripts/console_command.gd new file mode 100644 index 0000000..e14a5c2 --- /dev/null +++ b/scripts/console_command.gd @@ -0,0 +1,186 @@ +# Copyright (c) 2020-2022 Mansur Isaev and contributors - MIT License +# See `LICENSE.md` included in the source distribution for details. + +## Base ConsoleCommand class. +class_name ConsoleCommand +extends RefCounted + + +var _name : StringName +var _desc : String + +var _object_id : int +var _method : StringName + +var _arg_names : PackedStringArray +var _arg_types : PackedInt32Array + +## Return name of type. +static func get_type_name(type: int) -> String: + match type: + TYPE_BOOL: + return "bool" + TYPE_INT: + return "int" + TYPE_FLOAT: + return "float" + TYPE_STRING: + return "String" + TYPE_STRING_NAME: + return "StringName" + + return "" + + +func _get_method_info(object: Object, method: String) -> Dictionary: + if object.get_script(): + for m in object.get_script().get_script_method_list(): + if method == m.name: + return m + + for m in object.get_method_list(): + if method == m.name: + return m + + return {} + + +func _init_arguments(object: Object, method: String) -> void: + var method_info : Dictionary = _get_method_info(object, method) + + assert(method_info, "Method not found.") + if method_info.is_empty(): + return + + var arg_count = method_info.args.size() + + _arg_names.resize(arg_count) + _arg_types.resize(arg_count) + + for i in arg_count: + var arg : Dictionary = method_info.args[i] + + assert(is_valid_type(arg.type), "Invalid argument type.") + if not is_valid_type(arg.type): + continue + + _arg_types[i] = arg.type + + if arg.name: # Debug build. + _arg_names[i] = arg.name + else: # Release build. + _arg_names[i] = "arg" + str(i) + + +func _init(name: StringName, callable: Callable, desc: String) -> void: + assert(name != StringName(), "Invalid name.") + _name = name + _desc = desc + + assert(callable.is_valid(), "Invalid Callable.") + assert(callable.is_standard(), "Custom Callable is not supported.") + _object_id = callable.get_object_id() + _method = callable.get_method() + + _init_arguments(instance_from_id(_object_id), _method) + +## Return [param true] if type is valid. +func is_valid_type(type: int) -> bool: + match type: + TYPE_NIL: # Non static. + return true + TYPE_BOOL, TYPE_INT, TYPE_FLOAT: + return true + TYPE_STRING, TYPE_STRING_NAME: + return true + + return false + +## Return command name. +func get_name() -> StringName: + return _name + +## Return command description. +func get_description() -> String: + return _desc + +## Return object instance id. +func get_object_id() -> int: + return _object_id + +## Return Object. +func get_object() -> Object: + return instance_from_id(get_object_id()) + +## Return the name of the method. +func get_method() -> StringName: + return _method + +## Return the argument name. +func get_argument_name(index: int) -> String: + return _arg_names[index] + +## Return the argument type. +func get_argument_type(index: int) -> int: + return _arg_types[index] + +## Return the command arguments count. +func get_argument_count() -> int: + return _arg_types.size() + +## Return [param true] if command has arguments. +func has_argument() -> bool: + return get_argument_count() > 0 + +## Return [param true] if the command is valid. +func is_valid() -> bool: + return is_instance_id_valid(_object_id) + +## Return converted the string to valid type or null. +func convert_string(string: String, type: int) -> Variant: + assert(is_valid_type(type), "Invalid type.") + if not is_valid_type(type): + return null + + if type == TYPE_NIL or type == TYPE_STRING or type == TYPE_STRING_NAME: + return string # Non static argument or String return without changes. + elif type == TYPE_BOOL: + if string == "true": + return true + elif string == "false": + return false + elif string.is_valid_int(): + return string.to_int() + elif type == TYPE_INT and string.is_valid_int(): + return string.to_int() + elif type == TYPE_FLOAT and string.is_valid_float(): + return string.to_float() + + return null + +## Execute the command and return the result [String]. +func execute(arguments: PackedStringArray) -> String: + if not is_valid(): + return "[color=RED]Invalid object instance.[/color]" + + if get_argument_count() != arguments.size(): + return "[color=RED]Invalid argument count: Expected " + str(get_argument_count()) + ", received " + str(arguments.size()) + ".[/color]" + + var result = null + if has_argument(): + var arg_array : Array = [] + arg_array.resize(get_argument_count()) + + for i in get_argument_count(): + var value = convert_string(arguments[i], get_argument_type(i)) + + if value == null: + return "[color=YELLOW]Invalid argument type: Cannot convert argument " + str(i + 1) + " from \"String\" to \"" + get_type_name(get_argument_type(i)) + "\".[/color]" + + arg_array[i] = value + + result = get_object().callv(get_method(), arg_array) + else: + result = get_object().call(get_method()) + + return result if result is String else "" diff --git a/scripts/console_container.gd b/scripts/console_container.gd new file mode 100644 index 0000000..3275a5c --- /dev/null +++ b/scripts/console_container.gd @@ -0,0 +1,101 @@ +# Copyright (c) 2020-2022 Mansur Isaev and contributors - MIT License +# See `LICENSE.md` included in the source distribution for details. + +@tool +## Default container for Console output/input. +class_name ConsoleContainer +extends VBoxContainer + + +var _console : ConsoleNode + +var _console_output : RichTextLabel +var _console_input : LineEdit + +var _tooltip_panel : PanelContainer +var _tooltip_label : Label + + +func _init() -> void: + _console_output = RichTextLabel.new() + _console_output.size_flags_vertical = SIZE_EXPAND_FILL + _console_output.scroll_following = true + _console_output.selection_enabled = true + _console_output.focus_mode = Control.FOCUS_NONE + self.add_child(_console_output, false, Node.INTERNAL_MODE_FRONT) + + _console_input = LineEdit.new() + _console_input.context_menu_enabled = false + _console_input.clear_button_enabled = true + _console_input.caret_blink = true + _console_input.placeholder_text = "Command" + _console_input.clear_button_enabled = true + _console_input.editable = false + _console_input.text_changed.connect(set_input_text) + _console_input.gui_input.connect(_on_input_gui_event) + self.add_child(_console_input, false, Node.INTERNAL_MODE_FRONT) + + _tooltip_panel = PanelContainer.new() + _tooltip_panel.set_theme_type_variation(&"TooltipPanel") + _tooltip_panel.set_v_grow_direction(Control.GROW_DIRECTION_BEGIN) + _tooltip_panel.set_offset(SIDE_LEFT, 4.0) + _tooltip_panel.set_offset(SIDE_BOTTOM, -4.0) + _tooltip_panel.hide() + _console_input.add_child(_tooltip_panel) + + _tooltip_label = Label.new() + _tooltip_label.set_theme_type_variation(&"TooltipLabel") + _tooltip_panel.add_child(_tooltip_label) + + +func _enter_tree() -> void: + set_console(get_node_or_null("/root/Console")) + + +func set_console(console: ConsoleNode) -> void: + if _console == console: + return + + if is_instance_valid(_console): + _console.printed_line.disconnect(_console_output.append_text) + _console.cleared.disconnect(_console_output.clear) + + if is_instance_valid(console): + console.printed_line.connect(_console_output.append_text) + console.cleared.connect(_console_output.clear) + + _console = console + _console_input.editable = is_instance_valid(_console) + + +func get_console() -> ConsoleNode: + return _console + + +func set_input_text(text: String) -> void: + _console_input.text = text + _console_input.caret_column = text.length() + + var autocomplete := PackedStringArray() if text.is_empty() else _console.autocomplete_list(text) + + if autocomplete.is_empty(): + _tooltip_panel.hide() + else: + _tooltip_label.set_text("\n".join(autocomplete)) + _tooltip_panel.show() + + +func _on_input_gui_event(event: InputEvent) -> void: + if event.is_action_pressed(&"ui_text_completion_accept"): + _console.execute(_console_input.text) + _console_input.clear() + elif event.is_action_pressed(&"ui_text_indent"): + set_input_text(_console.autocomplete_command(_console_input.text)) + elif event.is_action_pressed(&"ui_text_caret_up"): + set_input_text(_console.get_prev_command()) + elif event.is_action_pressed(&"ui_text_caret_down"): + set_input_text(_console.get_next_command()) + else: + return + + _console_input.accept_event()