diff --git a/Mods/Tiers/Followers/follower_tier_1.png b/Mods/Tiers/Followers/follower_tier_1.png deleted file mode 100644 index f5912bc..0000000 Binary files a/Mods/Tiers/Followers/follower_tier_1.png and /dev/null differ diff --git a/Mods/Tiers/Huts/hut_tier_1.png b/Mods/Tiers/Huts/hut_tier_1.png deleted file mode 100644 index a557fbb..0000000 Binary files a/Mods/Tiers/Huts/hut_tier_1.png and /dev/null differ diff --git a/Mods/Tiers/Huts/hut_tier_2.png b/Mods/Tiers/Huts/hut_tier_2.png deleted file mode 100644 index a557fbb..0000000 Binary files a/Mods/Tiers/Huts/hut_tier_2.png and /dev/null differ diff --git a/Mods/Tiers/hut_tiers.json b/Mods/Tiers/hut_tiers.json index 994a584..8c751d5 100644 --- a/Mods/Tiers/hut_tiers.json +++ b/Mods/Tiers/hut_tiers.json @@ -3,13 +3,61 @@ { "tierEnum": "Tier1", "threshold": 150, - "imagePath": "res://Mods/Tiers/Huts/hut_tier_1.png", + "imagePath": "res://Sprites/Hut.png", "scale": {"x": 0.05, "y": 0.05} }, { "tierEnum": "Tier2", "threshold": 750, - "imagePath": "res://Mods/Tiers/Huts/hut_tier_2.png", + "imagePath": "res://Sprites/hut_tier_2.png", + "scale": {"x": 0.05, "y": 0.05} + }, + { + "tierEnum": "Tier3", + "threshold": 1000, + "imagePath": "res://Sprites/hut_tier_3.png", + "scale": {"x": 0.05, "y": 0.05} + }, + { + "tierEnum": "Tier4", + "threshold": 5000, + "imagePath": "res://Sprites/castle.png", + "scale": {"x": 0.05, "y": 0.05} + }, + { + "tierEnum": "Tier5", + "threshold": 7500, + "imagePath": "res://Sprites/house.png", + "scale": {"x": 0.05, "y": 0.05} + }, + { + "tierEnum": "Tier6", + "threshold": 10000, + "imagePath": "res://Sprites/house_tier_2.png", + "scale": {"x": 0.05, "y": 0.05} + }, + { + "tierEnum": "Tier7", + "threshold": 50000, + "imagePath": "res://Sprites/house_tier_3.png", + "scale": {"x": 0.05, "y": 0.05} + }, + { + "tierEnum": "Tier8", + "threshold": 150000, + "imagePath": "res://Sprites/Skyscraper.png", + "scale": {"x": 0.05, "y": 0.05} + }, + { + "tierEnum": "Tier9", + "threshold": 350000, + "imagePath": "res://Sprites/skyscraper_tier_2.png", + "scale": {"x": 0.05, "y": 0.05} + }, + { + "tierEnum": "Tier10", + "threshold": 550000, + "imagePath": "res://Sprites/skyscraper_tier_3.png", "scale": {"x": 0.05, "y": 0.05} } ] diff --git a/ParasiticGod.csproj b/ParasiticGod.csproj index 9f33686..f0a50d7 100644 --- a/ParasiticGod.csproj +++ b/ParasiticGod.csproj @@ -5,6 +5,7 @@ parasiticgod + \ No newline at end of file diff --git a/Scripts/Follower.cs b/Scripts/Follower.cs index fb28994..d990b32 100644 --- a/Scripts/Follower.cs +++ b/Scripts/Follower.cs @@ -5,6 +5,6 @@ namespace ParasiticGod.Scripts; [GlobalClass] public partial class Follower : Node2D { - public enum FollowerTier { Tier1, Tier2, Tier3, Tier4, Tier5 } + public enum FollowerTier { Tier1, Tier2, Tier3, Tier4, Tier5, Tier6, Tier7, Tier8, Tier9, Tier10 } [Export] public FollowerTier Tier { get; private set; } } \ No newline at end of file diff --git a/Scripts/Singletons/GameBus.cs b/Scripts/Singletons/GameBus.cs index 0dcd4d4..ce995a2 100644 --- a/Scripts/Singletons/GameBus.cs +++ b/Scripts/Singletons/GameBus.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Godot; +using Limbo.Console.Sharp; using ParasiticGod.Scripts.Core; using ParasiticGod.Scripts.Core.Effects; @@ -38,6 +39,11 @@ public partial class GameBus : Node Instance = null; } + public override void _Ready() + { + RegisterConsoleCommands(); + } + public override void _Process(double delta) { _gameLogic.UpdateGameState(_gameState, delta); @@ -66,7 +72,7 @@ public partial class GameBus : Node if (AllMiracles.TryGetValue(id, out var def) && !_gameState.IsMiracleUnlocked(id)) { miraclesToUnlock.Add(def); - _gameState.AddUnlockedMiracle(id); + _gameState.AddUnlockedMiracle(def.Id); } } } @@ -108,4 +114,7 @@ public partial class GameBus : Node public void UnsubscribeFromStat(Stat stat, Action listener) => _gameState.Unsubscribe(stat, listener); public GameState CurrentState => _gameState; + + [ConsoleCommand("set_stat", "Sets the value of a specified stat.")] + private void SetStatCommand(Stat stat, double value) => _gameState.Set(stat, value); } \ No newline at end of file diff --git a/Sprites/Skyscraper.png b/Sprites/Skyscraper.png new file mode 100644 index 0000000..eaeb811 Binary files /dev/null and b/Sprites/Skyscraper.png differ diff --git a/Mods/Tiers/Huts/hut_tier_1.png.import b/Sprites/Skyscraper.png.import similarity index 68% rename from Mods/Tiers/Huts/hut_tier_1.png.import rename to Sprites/Skyscraper.png.import index c97580c..332760e 100644 --- a/Mods/Tiers/Huts/hut_tier_1.png.import +++ b/Sprites/Skyscraper.png.import @@ -2,16 +2,16 @@ importer="texture" type="CompressedTexture2D" -uid="uid://c88ltenh4ghit" -path="res://.godot/imported/hut_tier_1.png-95c994b0565c43d199344569cb1a91ab.ctex" +uid="uid://b7shwf6ob3qk5" +path="res://.godot/imported/Skyscraper.png-dbe8acef55267af18c54925cfa634290.ctex" metadata={ "vram_texture": false } [deps] -source_file="res://Mods/Tiers/Huts/hut_tier_1.png" -dest_files=["res://.godot/imported/hut_tier_1.png-95c994b0565c43d199344569cb1a91ab.ctex"] +source_file="res://Sprites/Skyscraper.png" +dest_files=["res://.godot/imported/Skyscraper.png-dbe8acef55267af18c54925cfa634290.ctex"] [params] diff --git a/Sprites/Skyscraper_.png b/Sprites/Skyscraper_.png deleted file mode 100644 index 224ccf2..0000000 Binary files a/Sprites/Skyscraper_.png and /dev/null differ diff --git a/Sprites/house_tier_2.png b/Sprites/house_tier_2.png new file mode 100644 index 0000000..69984fb Binary files /dev/null and b/Sprites/house_tier_2.png differ diff --git a/Mods/Tiers/Followers/follower_tier_1.png.import b/Sprites/house_tier_2.png.import similarity index 66% rename from Mods/Tiers/Followers/follower_tier_1.png.import rename to Sprites/house_tier_2.png.import index e566e64..80d5199 100644 --- a/Mods/Tiers/Followers/follower_tier_1.png.import +++ b/Sprites/house_tier_2.png.import @@ -2,16 +2,16 @@ importer="texture" type="CompressedTexture2D" -uid="uid://2wsb1jsmtifq" -path="res://.godot/imported/follower_tier_1.png-d66a290ad46b1bb9ea69b54444ab5725.ctex" +uid="uid://8omh4jhf3dwy" +path="res://.godot/imported/house_tier_2.png-81b05409415d85bd1a2017792c00d1d0.ctex" metadata={ "vram_texture": false } [deps] -source_file="res://Mods/Tiers/Followers/follower_tier_1.png" -dest_files=["res://.godot/imported/follower_tier_1.png-d66a290ad46b1bb9ea69b54444ab5725.ctex"] +source_file="res://Sprites/house_tier_2.png" +dest_files=["res://.godot/imported/house_tier_2.png-81b05409415d85bd1a2017792c00d1d0.ctex"] [params] diff --git a/Sprites/house_tier_3.png b/Sprites/house_tier_3.png new file mode 100644 index 0000000..eb5aec8 Binary files /dev/null and b/Sprites/house_tier_3.png differ diff --git a/Sprites/house_tier_3.png.import b/Sprites/house_tier_3.png.import new file mode 100644 index 0000000..afc144a --- /dev/null +++ b/Sprites/house_tier_3.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6swd4nukhqw3" +path="res://.godot/imported/house_tier_3.png-6cbc031313f2c66f5365fe6cc5406e8f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Sprites/house_tier_3.png" +dest_files=["res://.godot/imported/house_tier_3.png-6cbc031313f2c66f5365fe6cc5406e8f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/Sprites/hut_tier_2.png b/Sprites/hut_tier_2.png new file mode 100644 index 0000000..d69320e Binary files /dev/null and b/Sprites/hut_tier_2.png differ diff --git a/Mods/Tiers/Huts/hut_tier_2.png.import b/Sprites/hut_tier_2.png.import similarity index 68% rename from Mods/Tiers/Huts/hut_tier_2.png.import rename to Sprites/hut_tier_2.png.import index 3a4bffb..827ba1d 100644 --- a/Mods/Tiers/Huts/hut_tier_2.png.import +++ b/Sprites/hut_tier_2.png.import @@ -2,16 +2,16 @@ importer="texture" type="CompressedTexture2D" -uid="uid://cgoigfok3s0fc" -path="res://.godot/imported/hut_tier_2.png-7e373825ef1bbf63f359ae57d709b394.ctex" +uid="uid://bvmc6om3x08a" +path="res://.godot/imported/hut_tier_2.png-3b01622803083ef93f5d9958304c62a4.ctex" metadata={ "vram_texture": false } [deps] -source_file="res://Mods/Tiers/Huts/hut_tier_2.png" -dest_files=["res://.godot/imported/hut_tier_2.png-7e373825ef1bbf63f359ae57d709b394.ctex"] +source_file="res://Sprites/hut_tier_2.png" +dest_files=["res://.godot/imported/hut_tier_2.png-3b01622803083ef93f5d9958304c62a4.ctex"] [params] diff --git a/Sprites/hut_tier_3.png b/Sprites/hut_tier_3.png new file mode 100644 index 0000000..119ebad Binary files /dev/null and b/Sprites/hut_tier_3.png differ diff --git a/Sprites/hut_tier_3.png.import b/Sprites/hut_tier_3.png.import new file mode 100644 index 0000000..81d38ec --- /dev/null +++ b/Sprites/hut_tier_3.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c047caxjt7n1h" +path="res://.godot/imported/hut_tier_3.png-7223c535ac426228250d1a42c2991446.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Sprites/hut_tier_3.png" +dest_files=["res://.godot/imported/hut_tier_3.png-7223c535ac426228250d1a42c2991446.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/Sprites/skyscraper_tier_2.png b/Sprites/skyscraper_tier_2.png new file mode 100644 index 0000000..5f8c1cc Binary files /dev/null and b/Sprites/skyscraper_tier_2.png differ diff --git a/Sprites/skyscraper_tier_2.png.import b/Sprites/skyscraper_tier_2.png.import new file mode 100644 index 0000000..c14e05d --- /dev/null +++ b/Sprites/skyscraper_tier_2.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjp0e05j0rut5" +path="res://.godot/imported/skyscraper_tier_2.png-08c46a0e23a9922ac087d37397d5de1d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Sprites/skyscraper_tier_2.png" +dest_files=["res://.godot/imported/skyscraper_tier_2.png-08c46a0e23a9922ac087d37397d5de1d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/Sprites/skyscraper_tier_3.png b/Sprites/skyscraper_tier_3.png new file mode 100644 index 0000000..af3e5d5 Binary files /dev/null and b/Sprites/skyscraper_tier_3.png differ diff --git a/Sprites/skyscraper_tier_3.png.import b/Sprites/skyscraper_tier_3.png.import new file mode 100644 index 0000000..16ece8a --- /dev/null +++ b/Sprites/skyscraper_tier_3.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cu5env6dkuc1u" +path="res://.godot/imported/skyscraper_tier_3.png-4b648ec2b3736510201be45be1a2c6a7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Sprites/skyscraper_tier_3.png" +dest_files=["res://.godot/imported/skyscraper_tier_3.png-4b648ec2b3736510201be45be1a2c6a7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/limbo_console.cfg b/addons/limbo_console.cfg new file mode 100644 index 0000000..1c53933 --- /dev/null +++ b/addons/limbo_console.cfg @@ -0,0 +1,39 @@ +[main] + +aliases={ +"exit": "quit", +"source": "exec", +"usage": "help" +} +disable_in_release_build=false +print_to_stdout=false +pause_when_open=true +commands_disabled_in_release=["eval"] + +[appearance] + +custom_theme="res://addons/limbo_console_theme.tres" +height_ratio=0.5 +open_speed=5.0 +opacity=1.0 +sparse_mode=false + +[greet] + +greet_user=true +greeting_message="{project_name}" +greet_using_ascii_art=true + +[history] + +persist_history=true +history_lines=1000 + +[autocomplete] + +autocomplete_use_history_with_matches=true + +[autoexec] + +autoexec_script="user://autoexec.lcs" +autoexec_auto_create=true diff --git a/addons/limbo_console/CONTRIBUTING.md b/addons/limbo_console/CONTRIBUTING.md new file mode 100644 index 0000000..feb500c --- /dev/null +++ b/addons/limbo_console/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing + +Thanks for your interest in contributing. Please follow these guidelines to keep the project consistent and maintainable in the long-term. + +## Vision + +- This is a simple text-based interface. Follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle) – keep the code simple and easy to maintain, abstractions minimal, and the API easy to use without reading a manual. +- Don't run any logic if the console is closed or hidden. + +## Code Style & Recommendations + +- Follow the official [GDScript Style Guide](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_styleguide.html). +- Use [static typing](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/static_typing.html) wherever possible. +- Apply the 80/20 rule: if a feature only benefits a small number of users, make it optional or don’t include it. +- To avoid unnecessary whitespace changes, please enable this setting in Godot: `Editor Settings > Text Editor > Behavior > Files > Trim Trailing Whitespace on Save` diff --git a/addons/limbo_console/LICENSE.md b/addons/limbo_console/LICENSE.md new file mode 100644 index 0000000..764fe19 --- /dev/null +++ b/addons/limbo_console/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Serhii Snitsaruk + +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/addons/limbo_console/README.md b/addons/limbo_console/README.md new file mode 100644 index 0000000..8dd865a --- /dev/null +++ b/addons/limbo_console/README.md @@ -0,0 +1,128 @@ +

+ LimboConsole logo +

+ +--- +![Limbo Console](.github/demonstration.gif) + +![Static Badge](https://img.shields.io/badge/Godot-4.3-blue?style=flat) +[![GitHub License](https://img.shields.io/github/license/limbonaut/limbo_console)](https://github.com/limbonaut/limbo_console/blob/master/LICENSE.md) + +A simple and easy-to-use in-game dev console with a command interpreter for Godot Engine 4. + +It supports auto-completion with `TAB` for commands and history, auto-correction, inline hints and highlighting, command help text generation, argument parsing for basic types, aliases, custom theming, and more. + +This plugin is currently in development, so expect breaking changes. + +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y2TCNH0) + + +## How to use + +> ℹ LimboConsole can be added as a Git submodule + +Place the source code in the `res://addons/limbo_console/` directory, and enable this plugin in the project settings, then reload the project. Toggle the console with the `GRAVE ACCENT` key (aka backtick - the key to the left of the `1` key). This can be changed in the Input Map tab in the project settings. + +Adding a new command is quite simple: + +```gdscript +func _ready() -> void: + LimboConsole.register_command(multiply) + +func multiply(a: float, b: float) -> void: + LimboConsole.info("a * b: " + str(a * b)) +``` + +> ℹ For C# support, see the next section. + +The example above adds a command that multiplies two numbers and prints the result (type `multiply 2 4`). Additionally, you can specify a name and a description: + +```gdscript +LimboConsole.register_command(multiply, "multiply", "multiply two numbers") +``` + +You can add a command as a subcommand of another command: + +```gdscript +# Register `multiply` as a subcommand under a new `math` command. +LimboConsole.register_command(multiply, "math multiply", "Multiply two numbers") +``` + +Now, you can enter `math multiply 2 4` in the console. By the way, the parent command doesn't have to exist. + +Several basic types are supported for command arguments, such as `bool`, `int`, `float`, `String` and `Vector{2,3,4}` types. To enter a `Vector2` argument, enclose its components in parentheses, like this: `(1 2)`. String arguments can also be enclosed in double quotation marks `"`. + +Autocompletion works for both command names and history. It can also be implemented for specific command arguments, as shown in the following example: +```gdscript +LimboConsole.register_command(teleport, "teleport", "teleport to site on this level") +LimboConsole.add_argument_autocomplete_source("teleport", 1, + func(): return ["entrance", "caves", "boss"] +) +``` +For a dynamically generated list of autocomplete values, the code could look like this: +```gdscript +LimboConsole.add_argument_autocomplete_source("teleport", 1, + func(): return get_tree().get_nodes_in_group("teleportation_site").map( + func(node): return node.name) +) +``` + +### Using in C# + +A community-maintained C# wrapper for this project is available as a NuGet package: https://github.com/ryan-linehan/limbo_console_sharp + +Thanks to @ryan-linehan for maintaining it! + +### Methods and properties + +Some notable methods and properties: + +- LimboConsole.enabled +- LimboConsole.register_command(callable, command_name, description) +- LimboConsole.unregister_command(callable_or_command_name) +- LimboConsole.add_alias(alias_name, command_name) +- LimboConsole.info(text_line) +- LimboConsole.error(text_line) +- LimboConsole.warning(text_line) +- LimboConsole.toggle_console() +- LimboConsole.add_argument_autocomplete_source(command_name, argument, callable) +- LimboConsole.execute_script(path, silent) + +This is not a complete list. For the rest, check out `limbo_console.gd`. + +### Keyboard Shortcuts + +- `Grave Accent` *(aka backtick - the key to the left of the `1` key)* — Toggle the console. +- `Enter` — Run entered command. +- `Tab` — Autocomplete command entry or cycle through autocomplete suggestions. +- `Shift+Tab` — Cycle through autocomplete suggestions in reverse. +- `Right` *(when cursor is at the end of command entry)* — Autocomplete according to inline hint (doesn't cycle like `Tab`). +- `Up/Down` — Cycle through command history, replacing contents of command entry. +- `Ctrl+R` — Toggle the history search interface (similar to [fzf](https://github.com/junegunn/fzf)). +- `Ctrl+C` *(when no text selected)* — Clear the command entry. + +### Configuration + +Options can be modified in the project-specific configuration file located at `res://addons/limbo_console.cfg`. This file is stored outside the plugin's directory to support adding the plugin as a Git submodule. + +LimboConsole also supports UI theming. Simply duplicate the `default_theme.tres` file and rename it to `limbo_console_theme.tres`. The file path is important - it should be located at `res://addons/limbo_console_theme.tres`. You can change this location in the config file. +Open the theme resource in Godot to customize it for your game. Console text colors can be adjusted in the `ConsoleColors` category. + +### Scripting + +You can execute simple scripts containing a sequence of commands: +```shell +exec lcs/my_script.lcs +``` + +Simple rules: +- Commands must be provided in the same syntax as in the prompt, with each command on a separate line. +- The script must exist at the specified path, either in the `res://` or `user://` directory. +- The script must have the `.lcs` extension, but when running the `exec` command, you can omit the extension in the command line. +- A line that starts with a '#' is treated as a comment and is not executed as part of the script. + +You can have a script execute automatically every time the game starts. There is a special script called `user://autoexec.lcs` that runs each time the game starts. This can be customized in the configuration file. + +### Contributing + +Check out [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/addons/limbo_console/ascii_art.gd b/addons/limbo_console/ascii_art.gd new file mode 100644 index 0000000..cc30e33 --- /dev/null +++ b/addons/limbo_console/ascii_art.gd @@ -0,0 +1,208 @@ +extends RefCounted + + +const boxed_map: Dictionary = { +'a': """ +▒▄▀█ +░█▀█ +""", +'b': """ +░█▄▄ +▒█▄█ +""", +'c': """ +░▄▀▀ +░▀▄▄ +""", +'d': """ +▒█▀▄ +░█▄▀ +""", +'e': """ +░██▀ +▒█▄▄ +""", +'f': """ +░█▀▀ +░█▀░ +""", +'g': """ +▒▄▀▀ +░▀▄█ +""", +'h': """ +░█▄█ +▒█▒█ +""", +'i': """ +░█ +░█ +""", +'j': """ +░░▒█ +░▀▄█ +""", +'k': """ +░█▄▀ +░█▒█ +""", +'l': """ +░█▒░ +▒█▄▄ +""", +'m': """ +▒█▀▄▀█ +░█▒▀▒█ +""", +'n': """ +░█▄░█ +░█▒▀█ +""", +'o': """ +░█▀█ +▒█▄█ +""", +'p': """ +▒█▀█ +░█▀▀ +""", +'q': """ +░▄▀▄ +░▀▄█ +""", +'r': """ +▒█▀█ +░█▀▄ +""", +'s': """ +░▄▀ +▒▄█ +""", +'t': """ +░▀█▀ +░▒█▒ +""", +'u': """ +░█░█ +▒█▄█ +""", +'v': """ +░█░█ +▒▀▄▀ +""", +'w': """ +▒█░█░█ +░▀▄▀▄▀ +""", +'x': """ +░▀▄▀ +░█▒█ +""", +'y': """ +░▀▄▀ +░▒█▒ +""", +'z': """ +░▀█ +▒█▄ +""", +' ': """ +░ +░ +""", +'_': """ +░░░ +▒▄▄ +""", +',': """ +░▒ +░█ +""", +'.': """ +░░ +░▄ +""", +'!': """ +░█ +░▄ +""", +'-': """ +░▒░ +░▀▀ +""", +'?': """ +░▀▀▄ +░▒█▀ +""", +'\'': """ +░▀ +░░ +""", +':': """ +░▄░ +▒▄▒ +""", +'0': """ +░▄▀▄ +░▀▄▀ +""", +'1': """ +░▄█ +░░█ +""", +'2': """ +░▀█ +░█▄ +""", +'3': """ +░▀██ +░▄▄█ +""", +'4': """ +░█▄ +░░█ +""", +'5': """ +░█▀ +░▄█ +""", +'6': """ +░█▀ +░██ +""", +'7': """ +░▀█ +░█░ +""", +'8': """ +░█▄█ +░█▄█ +""", +'9': """ +░██ +░▄█ +""", +} + +const unsupported_char := """ +░▒░ +▒░▒ +""" + + +static func str_to_boxed_art(p_text: String) -> PackedStringArray: + var lines: PackedStringArray = [] + lines.resize(2) + for c in p_text: + var ascii: String = boxed_map.get(c.to_lower(), unsupported_char) + var parts: PackedStringArray = ascii.split('\n') + lines[0] += parts[1] + lines[1] += parts[2] + return lines + + +static func is_boxed_art_supported(p_text: String) -> bool: + for c in p_text: + if not boxed_map.has(c.to_lower()): + return false + return true diff --git a/addons/limbo_console/ascii_art.gd.uid b/addons/limbo_console/ascii_art.gd.uid new file mode 100644 index 0000000..17004f0 --- /dev/null +++ b/addons/limbo_console/ascii_art.gd.uid @@ -0,0 +1 @@ +uid://coot1au4b7np diff --git a/addons/limbo_console/builtin_commands.gd b/addons/limbo_console/builtin_commands.gd new file mode 100644 index 0000000..6245727 --- /dev/null +++ b/addons/limbo_console/builtin_commands.gd @@ -0,0 +1,169 @@ +extends RefCounted +## BuiltinCommands + + +const Util := preload("res://addons/limbo_console/util.gd") + + +static func register_commands() -> void: + LimboConsole.register_command(cmd_alias, "alias", "add command alias") + LimboConsole.register_command(cmd_aliases, "aliases", "list all aliases") + LimboConsole.register_command(LimboConsole.clear_console, "clear", "clear console screen") + LimboConsole.register_command(cmd_commands, "commands", "list all commands") + LimboConsole.register_command(LimboConsole.info, "echo", "display a line of text") + LimboConsole.register_command(cmd_eval, "eval", "evaluate an expression") + LimboConsole.register_command(cmd_exec, "exec", "execute commands from file") + LimboConsole.register_command(cmd_fps_max, "fps_max", "limit framerate") + LimboConsole.register_command(cmd_fullscreen, "fullscreen", "toggle fullscreen mode") + LimboConsole.register_command(cmd_help, "help", "show command info") + LimboConsole.register_command(cmd_log, "log", "show recent log entries") + LimboConsole.register_command(cmd_quit, "quit", "exit the application") + LimboConsole.register_command(cmd_unalias, "unalias", "remove command alias") + LimboConsole.register_command(cmd_vsync, "vsync", "adjust V-Sync") + LimboConsole.register_command(LimboConsole.erase_history, "erase_history", "erases current history and persisted history") + LimboConsole.add_argument_autocomplete_source("help", 1, LimboConsole.get_command_names.bind(true)) + + +static func cmd_alias(p_alias: String, p_command: String) -> void: + LimboConsole.info("Adding %s => %s" % [LimboConsole.format_name(p_alias), p_command]) + LimboConsole.add_alias(p_alias, p_command) + + +static func cmd_aliases() -> void: + var aliases: Array = LimboConsole.get_aliases() + aliases.sort() + for alias in aliases: + var alias_argv: PackedStringArray = LimboConsole.get_alias_argv(alias) + var cmd_name: String = alias_argv[0] + var desc: String = LimboConsole.get_command_description(cmd_name) + alias_argv[0] = LimboConsole.format_name(cmd_name) + if desc.is_empty(): + LimboConsole.info(LimboConsole.format_name(alias)) + else: + LimboConsole.info("%s is alias of: %s %s" % [ + LimboConsole.format_name(alias), + ' '.join(alias_argv), + LimboConsole.format_tip(" // " + desc) + ]) + + +static func cmd_commands() -> void: + LimboConsole.info("Available commands:") + for name in LimboConsole.get_command_names(false): + var desc: String = LimboConsole.get_command_description(name) + name = LimboConsole.format_name(name) + LimboConsole.info(name if desc.is_empty() else "%s -- %s" % [name, desc]) + + +static func cmd_eval(p_expression: String) -> Error: + var exp := Expression.new() + var err: int = exp.parse(p_expression, LimboConsole.get_eval_input_names()) + if err != OK: + LimboConsole.error(exp.get_error_text()) + return err + var result = exp.execute(LimboConsole.get_eval_inputs(), + LimboConsole.get_eval_base_instance()) + if not exp.has_execute_failed(): + if result != null: + LimboConsole.info(str(result)) + return OK + else: + LimboConsole.error(exp.get_error_text()) + return ERR_SCRIPT_FAILED + + +static func cmd_exec(p_file: String, p_silent: bool = true) -> void: + if not p_file.ends_with(".lcs"): + # Prevent users from reading other game assets. + p_file += ".lcs" + if not FileAccess.file_exists(p_file): + p_file = "user://" + p_file + LimboConsole.execute_script(p_file, p_silent) + + +static func cmd_fps_max(p_limit: int = -1) -> void: + if p_limit < 0: + if Engine.max_fps == 0: + LimboConsole.info("Framerate is unlimited.") + else: + LimboConsole.info("Framerate is limited to %d FPS." % [Engine.max_fps]) + return + + Engine.max_fps = p_limit + if p_limit > 0: + LimboConsole.info("Limiting framerate to %d FPS." % [p_limit]) + elif p_limit == 0: + LimboConsole.info("Removing framerate limits.") + + +static func cmd_fullscreen() -> void: + if LimboConsole.get_viewport().mode == Window.MODE_WINDOWED: + # get_viewport().mode = Window.MODE_EXCLUSIVE_FULLSCREEN + LimboConsole.get_viewport().mode = Window.MODE_FULLSCREEN + LimboConsole.info("Window switched to fullscreen mode.") + else: + LimboConsole.get_viewport().mode = Window.MODE_WINDOWED + LimboConsole.info("Window switched to windowed mode.") + + +static func cmd_help(p_command_name: String = "") -> Error: + if p_command_name.is_empty(): + LimboConsole.print_line(LimboConsole.format_tip("Type %s to list all available commands." % + [LimboConsole.format_name("commands")])) + LimboConsole.print_line(LimboConsole.format_tip("Type %s to get more info about the command." % + [LimboConsole.format_name("help command")])) + return OK + else: + return LimboConsole.usage(p_command_name) + + +static func cmd_log(p_num_lines: int = 10) -> Error: + var fn: String = ProjectSettings.get_setting("debug/file_logging/log_path") + var file = FileAccess.open(fn, FileAccess.READ) + if not file: + LimboConsole.error("Can't open file: " + fn) + return ERR_CANT_OPEN + var contents := file.get_as_text() + var lines := contents.split('\n') + if lines.size() and lines[lines.size() - 1].strip_edges() == "": + lines.remove_at(lines.size() - 1) + lines = lines.slice(maxi(lines.size() - p_num_lines, 0)) + for line in lines: + LimboConsole.print_line(Util.bbcode_escape(line), false) + return OK + + +static func cmd_quit() -> void: + LimboConsole.get_tree().quit() + + +static func cmd_unalias(p_alias: String) -> void: + if LimboConsole.has_alias(p_alias): + LimboConsole.remove_alias(p_alias) + LimboConsole.info("Alias removed.") + else: + LimboConsole.warn("Alias not found.") + + +static func cmd_vsync(p_mode: int = -1) -> void: + if p_mode < 0: + var current: int = DisplayServer.window_get_vsync_mode() + if current == 0: + LimboConsole.info("V-Sync: disabled.") + elif current == 1: + LimboConsole.info('V-Sync: enabled.') + elif current == 2: + LimboConsole.info('Current V-Sync mode: adaptive.') + LimboConsole.info("Adjust V-Sync mode with an argument: 0 - disabled, 1 - enabled, 2 - adaptive.") + elif p_mode == DisplayServer.VSYNC_DISABLED: + LimboConsole.info("Changing to disabled.") + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + elif p_mode == DisplayServer.VSYNC_ENABLED: + LimboConsole.info("Changing to default V-Sync.") + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED) + elif p_mode == DisplayServer.VSYNC_ADAPTIVE: + LimboConsole.info("Changing to adaptive V-Sync.") + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ADAPTIVE) + else: + LimboConsole.error("Invalid mode.") + LimboConsole.info("Acceptable modes: 0 - disabled, 1 - enabled, 2 - adaptive.") diff --git a/addons/limbo_console/builtin_commands.gd.uid b/addons/limbo_console/builtin_commands.gd.uid new file mode 100644 index 0000000..04832d2 --- /dev/null +++ b/addons/limbo_console/builtin_commands.gd.uid @@ -0,0 +1 @@ +uid://npao6ckwv7cm diff --git a/addons/limbo_console/command_entry.gd b/addons/limbo_console/command_entry.gd new file mode 100644 index 0000000..aa01428 --- /dev/null +++ b/addons/limbo_console/command_entry.gd @@ -0,0 +1,130 @@ +extends TextEdit +## CommandEntry + + +signal text_submitted(command_line: String) +signal autocomplete_requested() + +var autocomplete_hint: String: + set(value): + if autocomplete_hint != value: + autocomplete_hint = value + queue_redraw() + +var _font: Font +var _font_size: int +var _hint_color: Color +var _sb_normal: StyleBox + +func _init() -> void: + syntax_highlighter = CommandEntryHighlighter.new() + + +func _ready() -> void: + caret_multiple = false + autowrap_mode = TextServer.AUTOWRAP_OFF + scroll_fit_content_height = true + # placeholder_text = "" + + get_v_scroll_bar().visibility_changed.connect(_hide_scrollbars) + get_h_scroll_bar().visibility_changed.connect(_hide_scrollbars) + _hide_scrollbars() + + _font = get_theme_font("font") + _font_size = get_theme_font_size("font_size") + _hint_color = get_theme_color("hint_color") + _sb_normal = get_theme_stylebox("normal") + + +func _notification(what: int) -> void: + match what: + NOTIFICATION_FOCUS_ENTER: + set_process_input(true) + NOTIFICATION_FOCUS_EXIT: + set_process_input(false) + + +func _input(event: InputEvent) -> void: + if not has_focus(): + return + if event is InputEventKey: + if event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER: + if event.is_pressed(): + submit_text() + get_viewport().set_input_as_handled() + elif event.keycode == KEY_C and event.get_modifiers_mask() == KEY_MASK_CTRL and get_selected_text().is_empty(): + # Clear input on Ctrl+C if no text selected. + if event.is_pressed(): + text = "" + text_changed.emit() + get_viewport().set_input_as_handled() + elif event.keycode in [KEY_RIGHT, KEY_END] and get_caret_column() == text.length(): + # Request autocomplete on RIGHT & END. + if event.is_pressed() and not autocomplete_hint.is_empty(): + autocomplete_requested.emit() + get_viewport().set_input_as_handled() + + +func _draw() -> void: + var offset_x: int = 0 + offset_x += _sb_normal.get_offset().x * 0.5 + offset_x += get_line_width(0) + + var offset_y: int = 0 + offset_y += _sb_normal.get_offset().y * 0.5 + offset_y += get_line_height() + 0.5 # + line_spacing + offset_y -= _font.get_descent(_font_size) + + draw_string(_font, Vector2(offset_x, offset_y), autocomplete_hint, 0, -1, _font_size, _hint_color) + + +func submit_text() -> void: + text_submitted.emit(text) + + +func _hide_scrollbars() -> void: + get_v_scroll_bar().hide() + get_h_scroll_bar().hide() + + +class CommandEntryHighlighter extends SyntaxHighlighter: + var command_found_color: Color + var subcommand_color: Color + var command_not_found_color: Color + var text_color: Color + + func _get_line_syntax_highlighting(line: int) -> Dictionary: + var text: String = get_text_edit().text + var command_end_idx: int = -1 # index where last recognized command ends (with subcommands) + + var argv: PackedStringArray = [] # argument vector (aka tokens) + var argi: PackedInt32Array = [] # argument starting indices in text + var start: int = 0 + var cur: int = 0 + for char in text + ' ': + if char == ' ': + if cur > start: + argv.append(text.substr(start, cur - start)) + argi.append(start) + var maybe_command: String = ' '.join(argv) + if LimboConsole.has_command(maybe_command) or LimboConsole.has_alias(maybe_command): + command_end_idx = cur + start = cur + 1 + cur += 1 + + var command_color: Color + var arg_start_idx: int = 0 # index where arguments start + + if command_end_idx > -1: + command_color = command_found_color + arg_start_idx = command_end_idx + 1 + else: + command_color = command_not_found_color + arg_start_idx = argi[1] if argi.size() > 1 else text.length() + + var result: Dictionary + result[0] = { "color": command_color } + if command_end_idx > -1 and argi.size() > 1: + result[argi[1]] = { "color": subcommand_color } + result[arg_start_idx] = { "color": text_color } + return result diff --git a/addons/limbo_console/command_entry.gd.uid b/addons/limbo_console/command_entry.gd.uid new file mode 100644 index 0000000..cfc9284 --- /dev/null +++ b/addons/limbo_console/command_entry.gd.uid @@ -0,0 +1 @@ +uid://ddtreeatktyov diff --git a/addons/limbo_console/command_history.gd b/addons/limbo_console/command_history.gd new file mode 100644 index 0000000..76687ad --- /dev/null +++ b/addons/limbo_console/command_history.gd @@ -0,0 +1,165 @@ +extends RefCounted +## Manages command history. + + +const HISTORY_FILE := "user://limbo_console_history.log" + + +var _entries: PackedStringArray +var _hist_idx = -1 +var _iterators: Array[WrappingIterator] +var _is_dirty: bool = false + + +func push_entry(p_entry: String) -> void: + _push_entry(p_entry) + _reset_iterators() + + +func _push_entry(p_entry: String) -> void: + var idx: int = _entries.find(p_entry) + if idx != -1: + # Duplicate commands not allowed in history. + _entries.remove_at(idx) + _entries.append(p_entry) + _is_dirty = true + + +func get_entry(p_index: int) -> String: + return _entries[clampi(p_index, 0, _entries.size())] + + +func create_iterator() -> WrappingIterator: + var it := WrappingIterator.new(_entries) + _iterators.append(it) + return it + + +func release_iterator(p_iter: WrappingIterator) -> void: + _iterators.erase(p_iter) + + +func size() -> int: + return _entries.size() + + +func trim(p_max_size: int) -> void: + if _entries.size() > p_max_size: + _entries.slice(p_max_size - _entries.size()) + _reset_iterators() + + +func clear() -> void: + _entries.clear() + + +func load(p_path: String = HISTORY_FILE) -> void: + var file := FileAccess.open(p_path, FileAccess.READ) + if not file: + return + while not file.eof_reached(): + var line: String = file.get_line().strip_edges() + if not line.is_empty(): + _push_entry(line) + file.close() + _reset_iterators() + _is_dirty = false + + +func save(p_path: String = HISTORY_FILE) -> void: + if not _is_dirty: + return + var file := FileAccess.open(p_path, FileAccess.WRITE) + if not file: + push_error("LimboConsole: Failed to save console history to file: ", p_path) + return + for line in _entries: + file.store_line(line) + file.close() + _is_dirty = false + + +## Searches history and returns an array starting with most relevant entries. +func fuzzy_match(p_query: String) -> PackedStringArray: + if len(p_query) == 0: + var copy := _entries.duplicate() + copy.reverse() + return copy + + var results: Array = [] + for entry: String in _entries: + var score: int = _compute_match_score(p_query.to_lower(), entry.to_lower()) + if score > 0: + results.append({"entry": entry, "score": score}) + + results.sort_custom(func(a, b): return a.score > b.score) + return results.map(func(rec): return rec.entry) + + +func _reset_iterators() -> void: + for it in _iterators: + it._reassign(_entries) + + +## Scoring function for fuzzy matching. +static func _compute_match_score(query: String, target: String) -> int: + var score: int = 0 + var query_index: int = 0 + + # Exact match. give unbeatable score + if query == target: + score = 99999 + return score + + for i in range(target.length()): + if query_index < query.length() and target[i] == query[query_index]: + score += 10 # Base score for a match + if i == 0 or target[i - 1] == " ": # Bonus for word start + score += 5 + query_index += 1 + if query_index == query.length(): + break + + # Ensure full query matches + return score if query_index == query.length() else 0 + + +## Iterator that wraps around and resets on history change. +class WrappingIterator: + extends RefCounted + + var _idx: int = -1 + var _entries: PackedStringArray + + + func _init(p_entries: PackedStringArray) -> void: + _entries = p_entries + + + func prev() -> String: + _idx = wrapi(_idx - 1, -1, _entries.size()) + if _idx == -1: + return String() + return _entries[_idx] + + + func next() -> String: + _idx = wrapi(_idx + 1, -1, _entries.size()) + if _idx == -1: + return String() + return _entries[_idx] + + + func current() -> String: + if _idx < 0 or _idx >= _entries.size(): + return String() + return _entries[_idx] + + + func reset() -> void: + _idx = -1 + + + func _reassign(p_entries: PackedStringArray) -> void: + _idx = -1 + _entries = p_entries diff --git a/addons/limbo_console/command_history.gd.uid b/addons/limbo_console/command_history.gd.uid new file mode 100644 index 0000000..7cc1345 --- /dev/null +++ b/addons/limbo_console/command_history.gd.uid @@ -0,0 +1 @@ +uid://dc55ouwu3ylf diff --git a/addons/limbo_console/config_mapper.gd b/addons/limbo_console/config_mapper.gd new file mode 100644 index 0000000..a076947 --- /dev/null +++ b/addons/limbo_console/config_mapper.gd @@ -0,0 +1,77 @@ +@tool +extends RefCounted +## Store object properties in an INI-style configuration file. + + +const CONFIG_PATH_PROPERTY := &"CONFIG_PATH" +const MAIN_SECTION_PROPERTY := &"MAIN_SECTION" +const MAIN_SECTION_DEFAULT := "main" + +static var verbose: bool = false + +static func _get_config_file(p_object: Object) -> String: + var from_object_constant = p_object.get(CONFIG_PATH_PROPERTY) + return from_object_constant if from_object_constant is String else "" + + +static func _get_main_section(p_object: Object) -> String: + var from_object_constant = p_object.get(MAIN_SECTION_PROPERTY) + return from_object_constant if from_object_constant != null else MAIN_SECTION_DEFAULT + + +static func _msg(p_text: String, p_arg1 = "") -> void: + if verbose: + print(p_text, p_arg1) + + +## Load object properties from config file and return status. [br] +## If p_config_path is empty, configuration path is taken from object's CONFIG_PATH property. +static func load_from_config(p_object: Object, p_config_path: String = "") -> int: + var config_path: String = p_config_path + if config_path.is_empty(): + config_path = _get_config_file(p_object) + var section: String = _get_main_section(p_object) + var config := ConfigFile.new() + var err: int = config.load(config_path) + if err != OK: + _msg("ConfigMapper: Failed to load config: %s err_code: %d" % [config_path, err]) + return err + _msg("ConfigMapper: Loading config: ", config_path) + + for prop_info in p_object.get_property_list(): + if prop_info.usage & PROPERTY_USAGE_CATEGORY and prop_info.hint_string.is_empty(): + _msg("ConfigMapper: Processing category: ", prop_info.name) + section = prop_info.name + elif prop_info.usage & PROPERTY_USAGE_SCRIPT_VARIABLE and prop_info.usage & PROPERTY_USAGE_STORAGE: + var value = null + if config.has_section_key(section, prop_info.name): + value = config.get_value(section, prop_info.name) + if value != null and typeof(value) == prop_info.type: + _msg("ConfigMapper: Loaded setting: %s section: %s value: %s" % [prop_info.name, section, value]) + p_object.set(prop_info.name, value) + _msg("ConfigMapper: Finished with code: ", OK) + return OK + + +## Save object properties to config file and return status. [br] +## If p_config_path is empty, configuration path is taken from object's CONFIG_PATH property. [br] +## WARNING: replaces file contents! +static func save_to_config(p_object: Object, p_config_path: String = "") -> int: + var config_path: String = p_config_path + if config_path.is_empty(): + config_path = _get_config_file(p_object) + var section: String = _get_main_section(p_object) + var config := ConfigFile.new() + _msg("ConfigMapper: Saving config: ", config_path) + + for prop_info in p_object.get_property_list(): + if prop_info.usage & PROPERTY_USAGE_CATEGORY and prop_info.hint_string.is_empty(): + _msg("ConfigMapper: Processing category: ", prop_info.name) + section = prop_info.name + elif prop_info.usage & PROPERTY_USAGE_SCRIPT_VARIABLE and prop_info.usage & PROPERTY_USAGE_STORAGE: + _msg("ConfigMapper: Saving setting: %s section: %s value: %s" % [prop_info.name, section, p_object.get(prop_info.name)]) + config.set_value(section, prop_info.name, p_object.get(prop_info.name)) + + var err: int = config.save(config_path) + _msg("ConfigMapper: Finished with code: ", err) + return err diff --git a/addons/limbo_console/config_mapper.gd.uid b/addons/limbo_console/config_mapper.gd.uid new file mode 100644 index 0000000..693014b --- /dev/null +++ b/addons/limbo_console/config_mapper.gd.uid @@ -0,0 +1 @@ +uid://dvokj0q23nb50 diff --git a/addons/limbo_console/console_options.gd b/addons/limbo_console/console_options.gd new file mode 100644 index 0000000..4b5ed0b --- /dev/null +++ b/addons/limbo_console/console_options.gd @@ -0,0 +1,40 @@ +extends RefCounted + +# Configuration is outside of limbo_console directory for compatibility with GIT submodules +const CONFIG_PATH := "res://addons/limbo_console.cfg" + +@export var aliases := { + "exit": "quit", + "source": "exec", + "usage": "help", +} +@export var disable_in_release_build: bool = false +@export var print_to_stdout: bool = false +@export var pause_when_open: bool = true + +@export var commands_disabled_in_release: Array = [ + "eval" # enables arbitrary code execution and asset extraction in the running game. +] + +@export_category("appearance") +@export var custom_theme: String = "res://addons/limbo_console_theme.tres" +@export var height_ratio: float = 0.5 +@export var open_speed: float = 5.0 # For instant, set to a really high float like 99999.0 +@export var opacity: float = 1.0 +@export var sparse_mode: bool = false # Print empty line after each command execution. + +@export_category("greet") +@export var greet_user: bool = true +@export var greeting_message: String = "{project_name}" +@export var greet_using_ascii_art: bool = true + +@export_category("history") +@export var persist_history: bool = true +@export var history_lines: int = 1000 + +@export_category("autocomplete") +@export var autocomplete_use_history_with_matches: bool = true + +@export_category("autoexec") +@export var autoexec_script: String = "user://autoexec.lcs" +@export var autoexec_auto_create: bool = true diff --git a/addons/limbo_console/console_options.gd.uid b/addons/limbo_console/console_options.gd.uid new file mode 100644 index 0000000..4918541 --- /dev/null +++ b/addons/limbo_console/console_options.gd.uid @@ -0,0 +1 @@ +uid://c7a12a1pe5esr diff --git a/addons/limbo_console/history_gui.gd b/addons/limbo_console/history_gui.gd new file mode 100644 index 0000000..5517ebb --- /dev/null +++ b/addons/limbo_console/history_gui.gd @@ -0,0 +1,256 @@ +extends Panel + +const CommandHistory := preload("res://addons/limbo_console/command_history.gd") + +# Visual Elements +var _last_highlighted_label: Label +var _history_labels: Array[Label] +var _scroll_bar: VScrollBar +var _scroll_bar_width: int = 12 + +# Indexing Results +var _command: String = "" # Needs default value so first search always processes +var _history: CommandHistory # Command history to search through +var _filter_results: PackedStringArray # Most recent results of performing a search for the _command in _history + +var _display_count: int = 0 # Number of history items to display in search +var _offset: int = 0 # The offset _filter_results +var _sub_index: int = 0 # The highlight index + +# Theme Cache +var _highlight_color: Color + + +# *** GODOT / VIRTUAL + + +func _init(p_history: CommandHistory) -> void: + _history = p_history + + set_anchors_preset(Control.PRESET_FULL_RECT) + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + + # Create first label, and set placeholder text to determine the display size + # once this node is _ready(). There should always be one label at minimum + # anyways since this search is usless without a way to show results. + var new_item := Label.new() + new_item.size_flags_vertical = Control.SIZE_SHRINK_END + new_item.size_flags_horizontal = Control.SIZE_EXPAND_FILL + new_item.text = "" + add_child(new_item) + _history_labels.append(new_item) + + _scroll_bar = VScrollBar.new() + add_child(_scroll_bar) + + +func _ready() -> void: + # The sizing of the labels is dependant on visiblity. + visibility_changed.connect(_calculate_display_count) + _scroll_bar.scrolling.connect(_scroll_bar_scrolled) + + _highlight_color = get_theme_color(&"history_highlight_color", &"ConsoleColors") + + +func _input(event: InputEvent) -> void: + if not is_visible_in_tree(): + return + + # Scroll up/down on mouse wheel up/down + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + _increment_index() + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _decrement_index() + + # Remaining inputs are key press handles + if event is not InputEventKey: + return + + # Increment/Decrement index + if event.keycode == KEY_UP and event.is_pressed(): + _increment_index() + get_viewport().set_input_as_handled() + elif event.keycode == KEY_DOWN and event.is_pressed(): + _decrement_index() + get_viewport().set_input_as_handled() + + +# *** PUBLIC + + +## Set visibility of history search +func set_visibility(p_visible: bool) -> void: + if not visible and p_visible: + # It's possible the _history has updated while not visible + # make sure the filtered list is up-to-date + _search_and_filter() + visible = p_visible + + +## Move cursor downwards +func _decrement_index() -> void: + var current_index: int = _get_current_index() + if current_index - 1 < 0: + return + + if _sub_index == 0: + _offset -= 1 + _update_scroll_list() + else: + _sub_index -= 1 + _update_highlight() + + +## Move cursor upwards +func _increment_index() -> void: + var current_index: int = _get_current_index() + if current_index + 1 >= _filter_results.size(): + return + + if _sub_index >= _display_count - 1: + _offset += 1 + _update_scroll_list() + else: + _sub_index += 1 + _update_highlight() + + +## Get the current selected text +func get_current_text() -> String: + var current_text: String = _command + if _history_labels.size() != 0 and _filter_results.size() != 0: + current_text = _filter_results[_get_current_index()] + return current_text + + +## Search for the command in the history +func search(command: String) -> void: + # Don't process if we used the same command before + if command == _command: + return + _command = command + + _search_and_filter() + + +# *** PRIVATE + + +## Update the text in the scroll list to match current offset and filtered results +func _update_scroll_list() -> void: + # Iterate through the number of displayed history items + for i in range(0, _display_count): + var filter_index: int = _offset + i + + # Default empty + _history_labels[i].text = "" + + # Set non empty if in range + var index_in_range: bool = filter_index < _filter_results.size() + if index_in_range: + _history_labels[i].text += _filter_results[filter_index] + + _update_scroll_bar() + + +## Highlight the subindex +func _update_highlight() -> void: + if _sub_index < 0 or _history.size() == 0: + return + + var style := StyleBoxFlat.new() + style.bg_color = _highlight_color + + # Always clear out the highlight of the last label + if is_instance_valid(_last_highlighted_label): + _last_highlighted_label.remove_theme_stylebox_override("normal") + + if _filter_results.size() <= 0: + return + + _history_labels[_sub_index].add_theme_stylebox_override("normal", style) + _last_highlighted_label = _history_labels[_sub_index] + + +## Get the current index of the selected item +func _get_current_index() -> int: + return _offset + _sub_index + + +## Reset offset and sub_indexes to scroll list back to bottom +func _reset_indexes() -> void: + _offset = 0 + _sub_index = 0 + + +## When the scrollbar has been scrolled (by mouse), scroll the list +func _scroll_bar_scrolled() -> void: + _offset = _scroll_bar.max_value - _display_count - _scroll_bar.value + _update_highlight() + _update_scroll_list() + + +func _calculate_display_count(): + if not visible: + return + # The display count is finnicky to get right due to the label needing to be + # rendered so the fize can be determined. This gets the job done, it ain't + # pretty, but it works + var max_y: float = size.y + + var label_size_y: float = (_history_labels[0] as Control).size.y + var label_size_x: float = size.x - _scroll_bar_width + + var display_count: int = int(max_y) / int(label_size_y) + if _display_count != display_count and display_count != 0 and display_count > _display_count: + _display_count = (display_count as int) + + # Since the labels are going from the bottom to the top, the label + # coordinates are offset from the bottom by label size. + # The first label already exists, so it's handlded by itself + _history_labels[0].position.y = size.y - label_size_y + _history_labels[0].set_size(Vector2(label_size_x, label_size_y)) + # The remaining labels may or may not exist already, create them + for i in range(0, _display_count - _history_labels.size()): + var new_item := Label.new() + new_item.size_flags_vertical = Control.SIZE_SHRINK_END + new_item.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + # The +1 is due to the labels going upwards from the bottom, otherwise + # their position will be 1 row lower than they should be + var position_offset: int = _history_labels.size() + 1 + new_item.position.y = size.y - (position_offset * label_size_y) + new_item.set_size(Vector2(label_size_x, label_size_y)) + _history_labels.append(new_item) + add_child(new_item) + + # Update the scroll bar to be positioned correctly + _scroll_bar.size.x = _scroll_bar_width + _scroll_bar.size.y = size.y + _scroll_bar.position.x = label_size_x + + _reset_history_to_beginning() + + +func _update_scroll_bar() -> void: + if _display_count > 0: + var max_size: int = _filter_results.size() + _scroll_bar.max_value = max_size + _scroll_bar.page = _display_count + _scroll_bar.set_value_no_signal((max_size - _display_count) - _offset) + + +## Reset indexes to 0, scroll to the bottom of the history list, and update visuals +func _reset_history_to_beginning() -> void: + _reset_indexes() + _update_highlight() + _update_scroll_list() + + +## Search for the current command and filter the results +func _search_and_filter() -> void: + _filter_results = _history.fuzzy_match(_command) + + _reset_history_to_beginning() diff --git a/addons/limbo_console/history_gui.gd.uid b/addons/limbo_console/history_gui.gd.uid new file mode 100644 index 0000000..5c17ca4 --- /dev/null +++ b/addons/limbo_console/history_gui.gd.uid @@ -0,0 +1 @@ +uid://cpl5jb0mwxanh diff --git a/addons/limbo_console/limbo_console.gd b/addons/limbo_console/limbo_console.gd new file mode 100644 index 0000000..e5787b2 --- /dev/null +++ b/addons/limbo_console/limbo_console.gd @@ -0,0 +1,1083 @@ +extends CanvasLayer +## LimboConsole + +signal toggled(is_shown) + +const THEME_DEFAULT := "res://addons/limbo_console/res/default_theme.tres" + +const AsciiArt := preload("res://addons/limbo_console/ascii_art.gd") +const BuiltinCommands := preload("res://addons/limbo_console/builtin_commands.gd") +const CommandEntry := preload("res://addons/limbo_console/command_entry.gd") +const ConfigMapper := preload("res://addons/limbo_console/config_mapper.gd") +const ConsoleOptions := preload("res://addons/limbo_console/console_options.gd") +const Util := preload("res://addons/limbo_console/util.gd") +const CommandHistory := preload("res://addons/limbo_console/command_history.gd") +const HistoryGui := preload("res://addons/limbo_console/history_gui.gd") + +const MAX_SUBCOMMANDS: int = 4 + +## If false, prevents console from being shown. Commands can still be executed from code. +var enabled: bool = true: + set(value): + enabled = value + set_process_input(enabled) + if not enabled and _control.visible: + _is_open = false + set_process(false) + _hide_console() + +var _control: Control +var _history_gui: HistoryGui +var _control_block: Control +var _output: RichTextLabel +var _entry: CommandEntry +var _previous_gui_focus: Control + +# Theme colors +var _output_command_color: Color +var _output_command_mention_color: Color +var _output_error_color: Color +var _output_warning_color: Color +var _output_text_color: Color +var _output_debug_color: Color +var _entry_text_color: Color +var _entry_hint_color: Color +var _entry_command_found_color: Color +var _entry_subcommand_color: Color +var _entry_command_not_found_color: Color + +var _options: ConsoleOptions +var _commands: Dictionary # "command" => Callable, or "command sub1 sub2" => Callable +var _aliases: Dictionary # "alias" => command_to_run: PackedStringArray (alias may contain subcommands) +var _command_descriptions: Dictionary # command_name => description_text +var _argument_autocomplete_sources: Dictionary # [command_name, arg_idx] => Callable +var _history: CommandHistory +var _history_iter: CommandHistory.WrappingIterator +var _autocomplete_matches: PackedStringArray +var _eval_inputs: Dictionary +var _silent: bool = false +var _was_already_paused: bool = false + +var _open_t: float = 0.0 +var _open_speed: float = 5.0 +var _is_open: bool = false + + +func _init() -> void: + layer = 9999 + process_mode = ProcessMode.PROCESS_MODE_ALWAYS + + _options = ConsoleOptions.new() + ConfigMapper.load_from_config(_options) + + _history = CommandHistory.new() + if _options.persist_history: + _history.load() + _history_iter = _history.create_iterator() + + _build_gui() + _init_theme() + _control.hide() + _control_block.hide() + + _open_speed = _options.open_speed + + if _options.disable_in_release_build: + enabled = OS.is_debug_build() + + +func _ready() -> void: + set_process(false) # Note, if you do it in _init(), it won't actually stop it for some reason. + BuiltinCommands.register_commands() + if _options.greet_user: + _greet() + _add_aliases_from_config.call_deferred() + _run_autoexec_script.call_deferred() + + _entry.autocomplete_requested.connect(_autocomplete) + _entry.text_submitted.connect(_on_entry_text_submitted) + _entry.text_changed.connect(_on_entry_text_changed) + + +func _exit_tree() -> void: + if _options.persist_history: + _history.trim(_options.history_lines) + _history.save() + + +func _handle_command_input(p_event: InputEvent) -> void: + var handled := true + if not _is_open: + pass # Don't accept input while closing console. + elif p_event.keycode == KEY_UP: + _fill_entry(_history_iter.prev()) + _clear_autocomplete() + _update_autocomplete() + elif p_event.keycode == KEY_DOWN: + _fill_entry(_history_iter.next()) + _clear_autocomplete() + _update_autocomplete() + elif p_event.is_action_pressed("limbo_auto_complete_reverse"): + _reverse_autocomplete() + elif p_event.keycode == KEY_TAB: + _autocomplete() + elif p_event.keycode == KEY_PAGEUP: + var scroll_bar: VScrollBar = _output.get_v_scroll_bar() + scroll_bar.value -= scroll_bar.page + elif p_event.keycode == KEY_PAGEDOWN: + var scroll_bar: VScrollBar = _output.get_v_scroll_bar() + scroll_bar.value += scroll_bar.page + else: + handled = false + if handled: + get_viewport().set_input_as_handled() + + +func _handle_history_input(p_event: InputEvent): + # Allow tab complete (reverse) + if p_event.is_action_pressed("limbo_auto_complete_reverse"): + _reverse_autocomplete() + get_viewport().set_input_as_handled() + # Allow tab complete (forward) + elif p_event.keycode == KEY_TAB and p_event.is_pressed(): + _autocomplete() + get_viewport().set_input_as_handled() + # Perform search + elif p_event is InputEventKey: + _history_gui.search(_entry.text) + _entry.grab_focus() + + # Make sure entry is always focused + _entry.grab_focus() + + +func _input(p_event: InputEvent) -> void: + if p_event.is_action_pressed("limbo_console_toggle"): + toggle_console() + get_viewport().set_input_as_handled() + # Check to see if the history gui should open + elif _control.visible and p_event.is_action_pressed("limbo_console_search_history"): + toggle_history() + get_viewport().set_input_as_handled() + elif _history_gui.visible and p_event is InputEventKey: + _handle_history_input(p_event) + elif _control.visible and p_event is InputEventKey and p_event.is_pressed(): + _handle_command_input(p_event) + + +func _process(delta: float) -> void: + var done_sliding := false + if _is_open: + _open_t = move_toward(_open_t, 1.0, _open_speed * delta * 1.0/Engine.time_scale) + if _open_t == 1.0: + done_sliding = true + else: # We close faster than opening. + _open_t = move_toward(_open_t, 0.0, _open_speed * delta * 1.5 * 1.0/Engine.time_scale) + if is_zero_approx(_open_t): + done_sliding = true + + var eased := ease(_open_t, -1.75) + var new_y := remap(eased, 0, 1, -_control.size.y, 0) + _control.position.y = new_y + + if done_sliding: + set_process(false) + if not _is_open: + _hide_console() + + +# *** PUBLIC INTERFACE + + +func open_console() -> void: + if enabled: + _is_open = true + set_process(true) + _show_console() + + +func close_console() -> void: + if enabled: + _is_open = false + set_process(true) + _history_gui.visible = false + if _options.persist_history: + _history.save() + # _hide_console() is called in _process() + + +func is_open() -> bool: + return _is_open + + +func toggle_console() -> void: + if _is_open: + close_console() + else: + open_console() + + +func toggle_history() -> void: + _history_gui.set_visibility(not _history_gui.visible) + # Whenever the history gui becomes visible, make sure it has the latest + # history and do an initial search + if _history_gui.visible: + _history_gui.search(_entry.text) + + +## Clears all messages in the console. +func clear_console() -> void: + _output.text = "" + + +## Erases the history that is persisted to the disk +func erase_history() -> void: + _history.clear() + var file := FileAccess.open(CommandHistory.HISTORY_FILE, FileAccess.WRITE) + if file: + file.store_string("") + file.close() + + +## Prints an info message to the console and the output. +func info(p_line: String) -> void: + print_line(p_line) + + +## Prints an error message to the console and the output. +func error(p_line: String) -> void: + print_line("[color=%s]ERROR:[/color] %s" % [_output_error_color.to_html(), p_line]) + + +## Prints a warning message to the console and the output. +func warn(p_line: String) -> void: + print_line("[color=%s]WARNING:[/color] %s" % [_output_warning_color.to_html(), p_line]) + + +## Prints a debug message to the console and the output. +func debug(p_line: String) -> void: + print_line("[color=%s]DEBUG: %s[/color]" % [_output_debug_color.to_html(), p_line]) + + +## Prints a line using boxed ASCII art style. +func print_boxed(p_line: String) -> void: + for line in AsciiArt.str_to_boxed_art(p_line): + print_line(line) + + +## Prints a line to the console, and optionally to standard output. +func print_line(p_line: String, p_stdout: bool = _options.print_to_stdout) -> void: + if _silent: + return + _output.text += p_line + "\n" + if p_stdout: + print(Util.bbcode_strip(p_line)) + + +## Registers a callable as a command, with optional name and description. +## Name can have up to 4 space-separated identifiers (e.g., "command sub1 sub2 sub3"), +## using letters, digits, or underscores, starting with a non-digit. +func register_command(p_func: Callable, p_name: String = "", p_desc: String = "") -> void: + if p_name and not Util.is_valid_command_sequence(p_name): + push_error("LimboConsole: Failed to register command: %s. Name can have up to 4 space-separated identifiers, using letters, digits, or underscores, starting with non-digit." % [p_name]) + return + + if not _validate_callable(p_func): + push_error("LimboConsole: Failed to register command: %s" % [p_func if p_name.is_empty() else p_name]) + return + var name: String = p_name + if name.is_empty(): + if p_func.is_custom(): + push_error("LimboConsole: Failed to register command: Callable is not method and no name was provided") + return + name = p_func.get_method().trim_prefix("_").trim_prefix("cmd_") + if not OS.is_debug_build() and _options.commands_disabled_in_release.has(name): + return + if _commands.has(name): + push_error("LimboConsole: Command already registered: " + p_name) + return + # Note: It should be possible to have an alias with the same name. + _commands[name] = p_func + _command_descriptions[name] = p_desc + + +## Unregisters the command specified by its name or a callable. +func unregister_command(p_func_or_name) -> void: + var cmd_name: String + if p_func_or_name is Callable: + var key = _commands.find_key(p_func_or_name) + if key != null: + cmd_name = key + elif p_func_or_name is String: + cmd_name = p_func_or_name + if cmd_name.is_empty() or not _commands.has(cmd_name): + push_error("LimboConsole: Unregister failed - command not found: " % [p_func_or_name]) + return + + _commands.erase(cmd_name) + _command_descriptions.erase(cmd_name) + + for i in range(1, 5): + _argument_autocomplete_sources.erase([cmd_name, i]) + + +## Is a command or an alias registered by the given name. +func has_command(p_name: String) -> bool: + return _commands.has(p_name) + + +func get_command_names(p_include_aliases: bool = false) -> PackedStringArray: + var names: PackedStringArray = _commands.keys() + if p_include_aliases: + names.append_array(_aliases.keys()) + names.sort() + return names + + +func get_command_description(p_name: String) -> String: + return _command_descriptions.get(p_name, "") + + +## Registers an alias for command line. [br] +## Alias may contain space-separated parts, e.g. "command sub1" which must match +## against two subsequent arguments on the command line. +func add_alias(p_alias: String, p_command_to_run: String) -> void: + # It should be possible to override commands and existing aliases. + # It should be possible to create aliases for commands that are not yet registered, + # because some commands may be registered by local-to-scene scripts. + _aliases[p_alias] = _parse_command_line(p_command_to_run) + + +## Removes an alias by name. +func remove_alias(p_name: String) -> void: + _aliases.erase(p_name) + + +## Is an alias registered by the given name. +func has_alias(p_name: String) -> bool: + return _aliases.has(p_name) + + +## Lists all registered aliases. +func get_aliases() -> PackedStringArray: + return PackedStringArray(_aliases.keys()) + + +## Returns the alias's actual command as an argument vector. +func get_alias_argv(p_alias: String) -> PackedStringArray: + # TODO: I believe _aliases values are stored as an array so this iis unneccessary? + return _aliases.get(p_alias, [p_alias]).duplicate() + + +## Registers a callable that should return an array of possible values for the given argument and command. +## It will be used for autocompletion. +func add_argument_autocomplete_source(p_command: String, p_argument: int, p_source: Callable) -> void: + if not p_source.is_valid(): + push_error("LimboConsole: Can't add autocomplete source: source callable is not valid") + return + if not has_command(p_command): + push_error("LimboConsole: Can't add autocomplete source: command doesn't exist: ", p_command) + return + if p_argument < 0 or p_argument > 4: + push_error("LimboConsole: Can't add autocomplete source: argument index out of bounds: ", p_argument) + return + var argument_values = p_source.call() + if not _validate_autocomplete_result(argument_values, p_command): + push_error("LimboConsole: Failed to add argument autocomplete source: Callable must return an array.") + return + var key := [p_command, p_argument] + _argument_autocomplete_sources[key] = p_source + + +## Parses the command line and executes the command if it's valid. +func execute_command(p_command_line: String, p_silent: bool = false) -> void: + p_command_line = p_command_line.strip_edges() + if p_command_line.is_empty() or p_command_line.strip_edges().begins_with('#'): + return + + var argv: PackedStringArray = _parse_command_line(p_command_line) + var expanded_argv: PackedStringArray = _join_subcommands(_expand_alias(argv)) + var command_name: String = expanded_argv[0] + var command_args: Array = [] + + _silent = p_silent + if not p_silent: + var history_line: String = " ".join(argv) + _history.push_entry(history_line) + info("[color=%s][b]>[/b] %s[/color] %s" % + [_output_command_color.to_html(), argv[0], " ".join(argv.slice(1))]) + + if not has_command(command_name): + error("Unknown command: " + command_name) + _suggest_similar_command(expanded_argv) + _silent = false + return + + var cmd: Callable = _commands.get(command_name) + var valid: bool = _parse_argv(expanded_argv, cmd, command_args) + if valid: + var err = cmd.callv(command_args) + var failed: bool = typeof(err) == TYPE_INT and err > 0 + if failed: + _suggest_argument_corrections(expanded_argv) + else: + usage(argv[0]) + if _options.sparse_mode: + print_line("") + _silent = false + + +## Execute commands from file. +func execute_script(p_file: String, p_silent: bool = true) -> void: + if FileAccess.file_exists(p_file): + if not p_silent: + info("Executing " + p_file); + var fa := FileAccess.open(p_file, FileAccess.READ) + while not fa.eof_reached(): + var line: String = fa.get_line() + execute_command(line, p_silent) + else: + error("File not found: " + p_file.trim_prefix("user://")) + + +## Formats the tip text (hopefully useful ;). +func format_tip(p_text: String) -> String: + return "[i][color=" + _output_debug_color.to_html() + "]" + p_text + "[/color][/i]" + + +## Formats the command name for display. +func format_name(p_name: String) -> String: + return "[color=" + _output_command_mention_color.to_html() + "]" + p_name + "[/color]" + + +## Prints the help text for the given command. +func usage(p_command: String) -> Error: + if _aliases.has(p_command): + var alias_argv: PackedStringArray = get_alias_argv(p_command) + var formatted_cmd := "%s %s" % [format_name(alias_argv[0]), ' '.join(alias_argv.slice(1))] + print_line("Alias of: " + formatted_cmd) + p_command = alias_argv[0] + + if not has_command(p_command): + error("Command not found: " + p_command) + return ERR_INVALID_PARAMETER + + var callable: Callable = _commands[p_command] + var method_info: Dictionary = Util.get_method_info(callable) + if method_info.is_empty(): + error("Couldn't find method info for: " + callable.get_method()) + print_line("Usage: ???") + + var usage_line: String = "Usage: %s" % [p_command] + var arg_lines: String = "" + var values_lines: String = "" + var required_args: int = method_info.args.size() - method_info.default_args.size() + + for i in range(method_info.args.size() - callable.get_bound_arguments_count()): + var arg_name: String = method_info.args[i].name.trim_prefix("p_") + var arg_type: int = method_info.args[i].type + if i < required_args: + usage_line += " " + arg_name + else: + usage_line += " [lb]" + arg_name + "[rb]" + var def_spec: String = "" + var num_required_args: int = method_info.args.size() - method_info.default_args.size() + if i >= num_required_args: + var def_value = method_info.default_args[i - num_required_args] + if typeof(def_value) == TYPE_STRING: + def_value = "\"" + def_value + "\"" + def_spec = " = %s" % [def_value] + arg_lines += " %s: %s%s\n" % [arg_name, type_string(arg_type) if arg_type != TYPE_NIL else "Variant", def_spec] + if _argument_autocomplete_sources.has([p_command, i]): + var auto_complete_callable: Callable = _argument_autocomplete_sources[[p_command, i]] + var arg_autocompletes = auto_complete_callable.call() + if len(arg_autocompletes) > 0: + var values: String = str(arg_autocompletes).replace("[", "").replace("]", "") + values_lines += " %s: %s\n" % [arg_name, values] + arg_lines = arg_lines.trim_suffix('\n') + + print_line(usage_line) + + var desc_line: String = "" + desc_line = _command_descriptions.get(p_command, "") + if not desc_line.is_empty(): + desc_line[0] = desc_line[0].capitalize() + if desc_line.right(1) != ".": + desc_line += "." + print_line(desc_line) + + if not arg_lines.is_empty(): + print_line("Arguments:") + print_line(arg_lines) + if not values_lines.is_empty(): + print_line("Values:") + print_line(values_lines) + return OK + + +## Define an input variable for "eval" command. +func add_eval_input(p_name: String, p_value) -> void: + _eval_inputs[p_name] = p_value + + +## Remove specified input variable from "eval" command. +func remove_eval_input(p_name) -> void: + _eval_inputs.erase(p_name) + + +## List the defined input variables used in "eval" command. +func get_eval_input_names() -> PackedStringArray: + return _eval_inputs.keys() + + +## Get input variable values used in "eval" command, listed in the same order as names. +func get_eval_inputs() -> Array: + return _eval_inputs.values() + + +## Define the object that will be used as the base instance for "eval" command. +## When defined, this object will be the "self" for expressions. +## Can be null (the default) to not use any base instance. +func set_eval_base_instance(object): + _eval_inputs["_base_instance"] = object + + +## Get the object that will be used as the base instance for "eval" command. +## Null by default. +func get_eval_base_instance(): + return _eval_inputs.get("_base_instance") + + +# *** PRIVATE + +# *** INITIALIZATION + + +func _build_gui() -> void: + var con := Control.new() # To block mouse input. + _control_block = con + con.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(con) + + var panel := PanelContainer.new() + _control = panel + panel.anchor_bottom = _options.height_ratio + panel.anchor_right = 1.0 + add_child(panel) + + var vbox := VBoxContainer.new() + vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + panel.add_child(vbox) + + _output = RichTextLabel.new() + _output.size_flags_vertical = Control.SIZE_EXPAND_FILL + _output.scroll_active = true + _output.scroll_following = true + _output.bbcode_enabled = true + _output.focus_mode = Control.FOCUS_CLICK + vbox.add_child(_output) + + _entry = CommandEntry.new() + vbox.add_child(_entry) + + _control.modulate = Color(1.0, 1.0, 1.0, _options.opacity) + + _history_gui = HistoryGui.new(_history) + _output.add_child(_history_gui) + _history_gui.visible = false + + +func _init_theme() -> void: + var theme: Theme + if ResourceLoader.exists(_options.custom_theme, "Theme"): + theme = load(_options.custom_theme) + else: + theme = load(THEME_DEFAULT) + _control.theme = theme + + const CONSOLE_COLORS_THEME_TYPE := &"ConsoleColors" + _output_command_color = theme.get_color(&"output_command_color", CONSOLE_COLORS_THEME_TYPE) + _output_command_mention_color = theme.get_color(&"output_command_mention_color", CONSOLE_COLORS_THEME_TYPE) + _output_text_color = theme.get_color(&"output_text_color", CONSOLE_COLORS_THEME_TYPE) + _output_error_color = theme.get_color(&"output_error_color", CONSOLE_COLORS_THEME_TYPE) + _output_warning_color = theme.get_color(&"output_warning_color", CONSOLE_COLORS_THEME_TYPE) + _output_debug_color = theme.get_color(&"output_debug_color", CONSOLE_COLORS_THEME_TYPE) + _entry_text_color = theme.get_color(&"entry_text_color", CONSOLE_COLORS_THEME_TYPE) + _entry_hint_color = theme.get_color(&"entry_hint_color", CONSOLE_COLORS_THEME_TYPE) + _entry_command_found_color = theme.get_color(&"entry_command_found_color", CONSOLE_COLORS_THEME_TYPE) + _entry_subcommand_color = theme.get_color(&"entry_subcommand_color", CONSOLE_COLORS_THEME_TYPE) + _entry_command_not_found_color = theme.get_color(&"entry_command_not_found_color", CONSOLE_COLORS_THEME_TYPE) + + _output.add_theme_color_override(&"default_color", _output_text_color) + _entry.add_theme_color_override(&"font_color", _entry_text_color) + _entry.add_theme_color_override(&"hint_color", _entry_hint_color) + _entry.syntax_highlighter.command_found_color = _entry_command_found_color + _entry.syntax_highlighter.command_not_found_color = _entry_command_not_found_color + _entry.syntax_highlighter.subcommand_color = _entry_subcommand_color + _entry.syntax_highlighter.text_color = _entry_text_color + + +func _greet() -> void: + var message: String = _options.greeting_message + message = message.format({ + "project_name": ProjectSettings.get_setting("application/config/name"), + "project_version": ProjectSettings.get_setting("application/config/version"), + }) + if not message.is_empty(): + if _options.greet_using_ascii_art and AsciiArt.is_boxed_art_supported(message): + print_boxed(message) + info("") + else: + info("[b]" + message + "[/b]") + BuiltinCommands.cmd_help() + info(format_tip("-----")) + + +func _add_aliases_from_config() -> void: + for alias in _options.aliases: + var target = _options.aliases[alias] + if not alias is String: + push_error("LimboConsole: Config error: Alias name should be String") + elif not target is String: + push_error("LimboConsole: Config error: Alias target should be String") + elif has_command(alias): + push_error("LimboConsole: Config error: Alias or command already registered: ", alias) + elif not has_command(target): + push_error("LimboConsole: Config error: Alias target not found: ", target) + else: + add_alias(alias, target) + + +func _run_autoexec_script() -> void: + if _options.autoexec_script.is_empty(): + return + if _options.autoexec_auto_create and not FileAccess.file_exists(_options.autoexec_script): + FileAccess.open(_options.autoexec_script, FileAccess.WRITE) + if FileAccess.file_exists(_options.autoexec_script): + execute_script(_options.autoexec_script) + + +# *** PARSING + + +## Splits the command line string into an array of arguments (aka argv). +func _parse_command_line(p_line: String) -> PackedStringArray: + var argv: PackedStringArray = [] + var arg: String = "" + var in_quotes: bool = false + var in_brackets: bool = false + var line: String = p_line.strip_edges() + var start: int = 0 + var cur: int = 0 + for char in line: + if char == '"': + in_quotes = not in_quotes + elif char == '(': + in_brackets = true + elif char == ')': + in_brackets = false + elif char == ' ' and not in_quotes and not in_brackets: + if cur > start: + argv.append(line.substr(start, cur - start)) + start = cur + 1 + cur += 1 + if cur > start: + argv.append(line.substr(start, cur)) + return argv + + +## Joins recognized subcommands in the argument vector into a single +## space-separated command sequence at index zero. +func _join_subcommands(p_argv: PackedStringArray) -> PackedStringArray: + for num_parts in range(MAX_SUBCOMMANDS, 1, -1): + if p_argv.size() >= num_parts: + var cmd: String = ' '.join(p_argv.slice(0, num_parts)) + if has_command(cmd) or has_alias(cmd): + var argv: PackedStringArray = [cmd] + return argv + p_argv.slice(num_parts) + return p_argv + + +## Substitutes an array of strings with its real command in argv. +## Will recursively expand aliases until no aliases are left. +func _expand_alias(p_argv: PackedStringArray) -> PackedStringArray: + var argv: PackedStringArray = p_argv.duplicate() + var result := PackedStringArray() + const max_depth: int = 1000 + var current_depth: int = 0 + while not argv.is_empty() and current_depth != max_depth: + argv = _join_subcommands(argv) + var current: String = argv[0] + argv.remove_at(0) + var alias_argv: PackedStringArray = _aliases.get(current, []) + current_depth += 1 + if not alias_argv.is_empty(): + argv = alias_argv + argv + else: + result.append(current) + if current_depth >= max_depth: + push_error("LimboConsole: Max depth for alias reached. Is there a loop in your aliasing?") + return p_argv + return result + + +## Converts arguments from String to types expected by the callable, and returns true if successful. +## The converted values are placed into a separate r_args array. +func _parse_argv(p_argv: PackedStringArray, p_callable: Callable, r_args: Array) -> bool: + var passed := true + + var method_info: Dictionary = Util.get_method_info(p_callable) + if method_info.is_empty(): + error("Couldn't find method info for: " + p_callable.get_method()) + return false + var num_bound_args: int = p_callable.get_bound_arguments_count() + var num_args: int = p_argv.size() + num_bound_args - 1 + var max_args: int = method_info.args.size() + var num_with_defaults: int = method_info.default_args.size() + var required_args: int = max_args - num_with_defaults + + # Join all arguments into a single string if the callable accepts a single string argument. + if max_args - num_bound_args == 1 and method_info.args[0].type == TYPE_STRING: + var a: String = " ".join(p_argv.slice(1)) + if a.left(1) == '"' and a.right(1) == '"': + a = a.trim_prefix('"').trim_suffix('"') + r_args.append(a) + return true + if num_args < required_args: + error("Missing arguments.") + return false + if num_args > max_args: + error("Too many arguments.") + return false + + r_args.resize(p_argv.size() - 1) + for i in range(1, p_argv.size()): + var a: String = p_argv[i] + var incorrect_type := false + var expected_type: int = method_info.args[i - 1].type + + if expected_type == TYPE_STRING: + if a.left(1) == '"' and a.right(1) == '"': + a = a.trim_prefix('"').trim_suffix('"') + r_args[i - 1] = a + elif a.begins_with('(') and a.ends_with(')'): + var vec = _parse_vector_arg(a) + if vec != null: + r_args[i - 1] = vec + else: + r_args[i - 1] = a + passed = false + elif a.is_valid_float(): + r_args[i - 1] = a.to_float() + elif a.is_valid_int(): + r_args[i - 1] = a.to_int() + elif a == "true" or a == "1" or a == "yes": + r_args[i - 1] = true + elif a == "false" or a == "0" or a == "no": + r_args[i - 1] = false + else: + r_args[i - 1] = a.trim_prefix('"').trim_suffix('"') + + var parsed_type: int = typeof(r_args[i - 1]) + + if not _are_compatible_types(expected_type, parsed_type): + error("Argument %d expects %s, but %s provided." % [i, type_string(expected_type), type_string(parsed_type)]) + passed = false + + return passed + + +## Returns true if the parsed type is compatible with the expected type. +func _are_compatible_types(p_expected_type: int, p_parsed_type: int) -> bool: + return p_expected_type == p_parsed_type or \ + p_expected_type == TYPE_NIL or \ + p_expected_type == TYPE_STRING or \ + (p_expected_type in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT] and p_parsed_type in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]) or \ + (p_expected_type in [TYPE_VECTOR2, TYPE_VECTOR2I] and p_parsed_type in [TYPE_VECTOR2, TYPE_VECTOR2I]) or \ + (p_expected_type in [TYPE_VECTOR3, TYPE_VECTOR3I] and p_parsed_type in [TYPE_VECTOR3, TYPE_VECTOR3I]) or \ + (p_expected_type in [TYPE_VECTOR4, TYPE_VECTOR4I] and p_parsed_type in [TYPE_VECTOR4, TYPE_VECTOR4I]) + + +func _parse_vector_arg(p_text): + assert(p_text.begins_with('(') and p_text.ends_with(')'), "Vector string presentation must begin and end with round brackets") + var comp: Array + var token: String + for i in range(1, p_text.length()): + var c: String = p_text[i] + if c.is_valid_int() or c == '.' or c == '-': + token += c + elif c == ',' or c == ' ' or c == ')': + if token.is_empty() and c == ',' and p_text[i - 1] in [',', '(']: + # Support shorthand notation: (,,1) => (0,0,1) + token = '0' + if token.is_valid_float(): + comp.append(token.to_float()) + token = "" + elif not token.is_empty(): + error("Failed to parse vector argument: Not a number: \"" + token + "\"") + info(format_tip("Tip: Supported formats are (1, 2, 3) and (1 2 3) with 2, 3 and 4 elements.")) + return null + else: + error("Failed to parse vector argument: Bad formatting: \"" + p_text + "\"") + info(format_tip("Tip: Supported formats are (1, 2, 3) and (1 2 3) with 2, 3 and 4 elements.")) + return null + if comp.size() == 2: + return Vector2(comp[0], comp[1]) + elif comp.size() == 3: + return Vector3(comp[0], comp[1], comp[2]) + elif comp.size() == 4: + return Vector4(comp[0], comp[1], comp[2], comp[3]) + else: + error("LimboConsole supports 2,3,4-element vectors, but %d-element vector given." % [comp.size()]) + return null + + +# *** AUTOCOMPLETE + +## Auto-completes a command or auto-correction on TAB. +func _autocomplete() -> void: + if not _autocomplete_matches.is_empty(): + var match_str: String = _autocomplete_matches[0] + _fill_entry(match_str) + _autocomplete_matches.remove_at(0) + _autocomplete_matches.push_back(match_str) + _update_autocomplete() + + +## Goes in the opposite direction for the autocomplete suggestion +func _reverse_autocomplete(): + if not _autocomplete_matches.is_empty(): + var match_str = _autocomplete_matches[_autocomplete_matches.size() - 1] + _autocomplete_matches.remove_at(_autocomplete_matches.size() - 1) + _autocomplete_matches.insert(0, match_str) + match_str = _autocomplete_matches[_autocomplete_matches.size() - 1] + _fill_entry(match_str) + _update_autocomplete() + + +## Updates autocomplete suggestions and hint based on user input. +func _update_autocomplete() -> void: + var argv: PackedStringArray = _expand_alias(_parse_command_line(_entry.text)) + if _entry.text.right(1) == ' ' or argv.size() == 0: + argv.append("") + var command_name: String = argv[0] + var last_arg: int = argv.size() - 1 + if _autocomplete_matches.is_empty() and not _entry.text.is_empty(): + if last_arg == 0 and not argv[0].is_empty() \ + and len(argv[0].split(" ")) <= 1: + _add_first_input_autocompletes(command_name) + elif last_arg != 0: + _add_argument_autocompletes(argv) + _add_subcommand_autocompletes(_entry.text) + _add_history_autocompletes() + + if _autocomplete_matches.size() > 0 \ + and _autocomplete_matches[0].length() > _entry.text.length() \ + and _autocomplete_matches[0].begins_with(_entry.text): + _entry.autocomplete_hint = _autocomplete_matches[0].substr(_entry.text.length()) + else: + _entry.autocomplete_hint = "" + + +## Adds auto completes for the first index of a registered +## commands when the command is split on " " +func _add_first_input_autocompletes(command_name: String) -> void: + for cmd_name in get_command_names(true): + var first_input: String = cmd_name.split(" ")[0] + if first_input.begins_with(command_name) and \ + first_input not in _autocomplete_matches: + _autocomplete_matches.append(first_input) + _autocomplete_matches.sort() + + +## Adds auto-completes based on user added arguments for a command. [br] +## p_argv is expected to contain full command as the first element (including subcommands). +func _add_argument_autocompletes(p_argv: PackedStringArray) -> void: + if p_argv.is_empty(): + return + var command: String = p_argv[0] + var last_arg: int = p_argv.size() - 1 + var key := [command, last_arg - 1] # Argument indices are 0-based. + if _argument_autocomplete_sources.has(key): + var argument_values = _argument_autocomplete_sources[key].call() + if not _validate_autocomplete_result(argument_values, command): + argument_values = [] + var matches: PackedStringArray = [] + for value in argument_values: + if str(value).begins_with(p_argv[last_arg]): + matches.append(_entry.text.substr(0, _entry.text.length() - p_argv[last_arg].length()) + str(value)) + matches.sort() + _autocomplete_matches.append_array(matches) + + +## Adds auto-completes based on the history +func _add_history_autocompletes() -> void: + if _options.autocomplete_use_history_with_matches or \ + len(_autocomplete_matches) == 0: + for i in range(_history.size() - 1, -1, -1): + if _history.get_entry(i).begins_with(_entry.text): + _autocomplete_matches.append(_history.get_entry(i)) + + +## Adds subcommand auto-complete suggestions based on registered commands +## and the current user input +func _add_subcommand_autocompletes(typed_val: String) -> void: + var command_names: PackedStringArray = get_command_names(true) + var typed_val_tokens: PackedStringArray = typed_val.split(" ") + var result: Dictionary = {} # Hashset. "autocomplete" => N/A + for cmd in command_names: + var cmd_split = cmd.split(" ") + if len(cmd_split) < len(typed_val_tokens): + continue + + var last_match: int = 0 + for i in len(typed_val_tokens): + if cmd_split[i] != typed_val_tokens[i]: + break + last_match += 1 + + if last_match < len(typed_val_tokens) - 1: + continue + + if len(cmd_split) >= len(typed_val_tokens) \ + and cmd_split[last_match].begins_with(typed_val_tokens[-1]): + var partial_cmd_arr: PackedStringArray = cmd_split.slice(0, last_match + 1) + result.get_or_add(" ".join(partial_cmd_arr)) + + var matches = result.keys() + matches.sort() + _autocomplete_matches.append_array(matches) + + +func _clear_autocomplete() -> void: + _autocomplete_matches.clear() + _entry.autocomplete_hint = "" + + +## Suggests corrections to user input based on similar command names. +func _suggest_similar_command(p_argv: PackedStringArray) -> void: + if _silent: + return + var fuzzy_hit: String = Util.fuzzy_match_string(p_argv[0], 2, get_command_names(true)) + if fuzzy_hit: + info(format_tip("Did you mean %s? ([b]TAB[/b] to fill)" % [format_name(fuzzy_hit)])) + var argv := p_argv.duplicate() + argv[0] = fuzzy_hit + var suggest_command: String = " ".join(argv) + suggest_command = suggest_command.strip_edges() + _autocomplete_matches.append(suggest_command) + + +## Suggests corrections to user input based on similar autocomplete argument values. +func _suggest_argument_corrections(p_argv: PackedStringArray) -> void: + if _silent: + return + var argv: PackedStringArray + var command_name: String = p_argv[0] + command_name = get_alias_argv(command_name)[0] + var corrected := false + + argv.resize(p_argv.size()) + argv[0] = command_name + for i in range(1, p_argv.size()): + var accepted_values = [] + var key := [command_name, i] + var source: Callable = _argument_autocomplete_sources.get(key, Callable()) + if source.is_valid(): + accepted_values = source.call() + if accepted_values == null or not _validate_autocomplete_result(accepted_values, command_name): + continue + var fuzzy_hit: String = Util.fuzzy_match_string(p_argv[i], 2, accepted_values) + if not fuzzy_hit.is_empty(): + argv[i] = fuzzy_hit + corrected = true + else: + argv[i] = p_argv[i] + if corrected: + info(format_tip("Did you mean \"%s %s\"? ([b]TAB[/b] to fill)" % [format_name(command_name), " ".join(argv.slice(1))])) + var suggest_command: String = " ".join(argv) + suggest_command = suggest_command.strip_edges() + _autocomplete_matches.append(suggest_command) + + +# *** MISC + + +func _show_console() -> void: + if not _control.visible and enabled: + _control.show() + _control_block.show() + if _options.pause_when_open: + _was_already_paused = get_tree().paused + if not _was_already_paused: + get_tree().paused = true + _previous_gui_focus = get_viewport().gui_get_focus_owner() + _entry.grab_focus() + toggled.emit(true) + + +func _hide_console() -> void: + if _control.visible: + _control.hide() + _control_block.hide() + + if _options.pause_when_open: + if not _was_already_paused: + get_tree().paused = false + if is_instance_valid(_previous_gui_focus): + _previous_gui_focus.grab_focus() + toggled.emit(false) + + +## Returns true if the callable can be registered as a command. +func _validate_callable(p_callable: Callable) -> bool: + var method_info: Dictionary = Util.get_method_info(p_callable) + if p_callable.is_standard() and method_info.is_empty(): + push_error("LimboConsole: Couldn't find method info for: " + p_callable.get_method()) + return false + if p_callable.is_custom() and not method_info.is_empty() \ + and method_info.get("name") == "" \ + and p_callable.get_bound_arguments_count() > 0: + push_error("LimboConsole: bound anonymous functions are unsupported") + return false + + var ret := true + for arg in method_info.args: + if not arg.type in [TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I]: + push_error("LimboConsole: Unsupported argument type: %s is %s" % [arg.name, type_string(arg.type)]) + ret = false + return ret + + +func _validate_autocomplete_result(p_result: Variant, p_command: String) -> bool: + if typeof(p_result) < TYPE_ARRAY: + push_error("LimboConsole: Argument autocomplete source failed: Expecting array but got: ", + type_string(typeof(p_result)), " command: ", p_command) + return false + return true + + +func _fill_entry(p_line: String) -> void: + _entry.text = p_line + _entry.set_caret_column(p_line.length()) + + +func _on_entry_text_submitted(p_command: String) -> void: + if _history_gui.visible: + _history_gui.visible = false + _clear_autocomplete() + _fill_entry(_history_gui.get_current_text()) + _update_autocomplete() + else: + _clear_autocomplete() + _fill_entry("") + execute_command(p_command) + _update_autocomplete() + + +func _on_entry_text_changed() -> void: + _clear_autocomplete() + if not _entry.text.is_empty(): + _update_autocomplete() + else: + _history_iter.reset() diff --git a/addons/limbo_console/limbo_console.gd.uid b/addons/limbo_console/limbo_console.gd.uid new file mode 100644 index 0000000..63a4ea4 --- /dev/null +++ b/addons/limbo_console/limbo_console.gd.uid @@ -0,0 +1 @@ +uid://dyxornv8vwibg diff --git a/addons/limbo_console/plugin.cfg b/addons/limbo_console/plugin.cfg new file mode 100644 index 0000000..7d7ad8d --- /dev/null +++ b/addons/limbo_console/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="LimboConsole" +description="Yet another in-game console with a simple command interpreter." +author="Serhii Snitsaruk" +version="0.4.1" +script="plugin.gd" diff --git a/addons/limbo_console/plugin.gd b/addons/limbo_console/plugin.gd new file mode 100644 index 0000000..a677aad --- /dev/null +++ b/addons/limbo_console/plugin.gd @@ -0,0 +1,57 @@ +@tool +extends EditorPlugin + +const ConsoleOptions := preload("res://addons/limbo_console/console_options.gd") +const ConfigMapper := preload("res://addons/limbo_console/config_mapper.gd") + +func _enter_tree() -> void: + add_autoload_singleton("LimboConsole", "res://addons/limbo_console/limbo_console.gd") + + # Sync config file (create if not exists) + var console_options := ConsoleOptions.new() + var do_project_setting_save: bool = false + ConfigMapper.load_from_config(console_options) + ConfigMapper.save_to_config(console_options) + + if not ProjectSettings.has_setting("input/limbo_console_toggle"): + print("LimboConsole: Adding \"limbo_console_toggle\" input action to project settings...") + + var key_event := InputEventKey.new() + key_event.keycode = KEY_QUOTELEFT + + ProjectSettings.set_setting("input/limbo_console_toggle", { + "deadzone": 0.5, + "events": [key_event], + }) + do_project_setting_save = true + + if not ProjectSettings.has_setting("input/limbo_auto_complete_reverse"): + print("LimboConsole: Adding \"limbo_auto_complete_reverse\" input action to project settings...") + var key_event = InputEventKey.new() + key_event.keycode = KEY_TAB + key_event.shift_pressed = true + + ProjectSettings.set_setting("input/limbo_auto_complete_reverse", { + "deadzone": 0.5, + "events": [key_event], + }) + do_project_setting_save = true + + if not ProjectSettings.has_setting("input/limbo_console_search_history"): + print("LimboConsole: Adding \"limbo_console_search_history\" input action to project settings...") + var key_event = InputEventKey.new() + key_event.keycode = KEY_R + key_event.ctrl_pressed = true + + ProjectSettings.set_setting("input/limbo_console_search_history", { + "deadzone": 0.5, + "events": [key_event], + }) + do_project_setting_save = true + + if do_project_setting_save: + ProjectSettings.save() + + +func _exit_tree() -> void: + remove_autoload_singleton("LimboConsole") diff --git a/addons/limbo_console/plugin.gd.uid b/addons/limbo_console/plugin.gd.uid new file mode 100644 index 0000000..3ae60d3 --- /dev/null +++ b/addons/limbo_console/plugin.gd.uid @@ -0,0 +1 @@ +uid://b4t0bfyjdn8i0 diff --git a/addons/limbo_console/res/default_theme.tres b/addons/limbo_console/res/default_theme.tres new file mode 100644 index 0000000..6b43cf8 --- /dev/null +++ b/addons/limbo_console/res/default_theme.tres @@ -0,0 +1,176 @@ +[gd_resource type="Theme" load_steps=23 format=3 uid="uid://dq4nntds66bix"] + +[ext_resource type="FontFile" uid="uid://dbbw833hg2v7o" path="res://addons/limbo_console/res/fonts/monaspace_argon_bold.otf" id="1_cry2i"] +[ext_resource type="FontFile" uid="uid://dmeyp84repfbw" path="res://addons/limbo_console/res/fonts/monaspace_argon_bold_italic.otf" id="2_h3f73"] +[ext_resource type="FontFile" uid="uid://dhm45nttm5i3s" path="res://addons/limbo_console/res/fonts/monaspace_argon_italic.otf" id="3_2qq11"] +[ext_resource type="FontFile" uid="uid://ds7dvyquauqub" path="res://addons/limbo_console/res/fonts/monaspace_argon_medium.otf" id="4_06w3f"] +[ext_resource type="FontFile" uid="uid://d4js20k8kslqt" path="res://addons/limbo_console/res/fonts/monaspace_argon_regular.otf" id="5_p4ppy"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_yia2g"] +content_margin_left = 6.0 +content_margin_top = 4.0 +content_margin_right = 6.0 +content_margin_bottom = 4.0 +bg_color = Color(0.147, 0.168, 0.203, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 3 +anti_aliasing = false + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_bt363"] +content_margin_left = 2.0 +content_margin_top = 4.0 +content_margin_right = 2.0 +content_margin_bottom = 4.0 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txn37"] +content_margin_left = 2.0 +content_margin_top = 4.0 +content_margin_right = 2.0 +content_margin_bottom = 4.0 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ypc4n"] +content_margin_left = 2.0 +content_margin_top = 4.0 +content_margin_right = 2.0 +content_margin_bottom = 4.0 +bg_color = Color(0, 0, 0, 0) +border_width_top = 1 +border_color = Color(0.211765, 0.239216, 0.290196, 1) + +[sub_resource type="Image" id="Image_jqg6r"] +data = { +"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 3, 255, 255, 255, 41, 255, 255, 255, 67, 255, 255, 255, 67, 255, 255, 255, 40, 255, 255, 255, 3, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 41, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 40, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 67, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 67, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 67, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 67, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 40, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 74, 255, 255, 255, 40, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 3, 255, 255, 255, 40, 255, 255, 255, 67, 255, 255, 255, 67, 255, 255, 255, 40, 255, 255, 255, 3, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0), +"format": "RGBA8", +"height": 12, +"mipmaps": false, +"width": 12 +} + +[sub_resource type="ImageTexture" id="ImageTexture_utg8u"] +image = SubResource("Image_jqg6r") + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_xismo"] +content_margin_left = 7.0 +content_margin_top = 7.0 +content_margin_right = 7.0 +content_margin_bottom = 7.0 +texture = SubResource("ImageTexture_utg8u") +texture_margin_left = 6.0 +texture_margin_top = 6.0 +texture_margin_right = 6.0 +texture_margin_bottom = 6.0 + +[sub_resource type="Image" id="Image_rt1dl"] +data = { +"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 6, 248, 248, 248, 102, 249, 249, 249, 168, 249, 249, 249, 168, 248, 248, 248, 101, 213, 213, 213, 6, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 248, 248, 248, 102, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 186, 248, 248, 248, 101, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 249, 249, 249, 168, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 168, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 249, 249, 249, 168, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 186, 248, 248, 248, 168, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 248, 248, 248, 101, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 186, 249, 249, 249, 186, 250, 250, 250, 99, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 213, 213, 213, 6, 248, 248, 248, 101, 249, 249, 249, 168, 248, 248, 248, 168, 250, 250, 250, 99, 213, 213, 213, 6, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0), +"format": "RGBA8", +"height": 12, +"mipmaps": false, +"width": 12 +} + +[sub_resource type="ImageTexture" id="ImageTexture_foc76"] +image = SubResource("Image_rt1dl") + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_oajxf"] +content_margin_left = 6.0 +content_margin_top = 6.0 +content_margin_right = 6.0 +content_margin_bottom = 6.0 +texture = SubResource("ImageTexture_foc76") +texture_margin_left = 5.0 +texture_margin_top = 5.0 +texture_margin_right = 5.0 +texture_margin_bottom = 5.0 + +[sub_resource type="Image" id="Image_2c5qw"] +data = { +"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 213, 213, 213, 6, 180, 180, 180, 102, 181, 181, 181, 168, 181, 181, 181, 168, 179, 179, 179, 101, 170, 170, 170, 6, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 180, 180, 180, 102, 180, 180, 180, 186, 180, 180, 180, 186, 180, 180, 180, 186, 180, 180, 180, 186, 179, 179, 179, 101, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 181, 181, 181, 168, 180, 180, 180, 186, 180, 180, 180, 186, 180, 180, 180, 186, 180, 180, 180, 186, 181, 181, 181, 168, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 181, 181, 181, 168, 180, 180, 180, 186, 180, 180, 180, 186, 180, 180, 180, 186, 180, 180, 180, 186, 179, 179, 179, 168, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 179, 179, 179, 101, 180, 180, 180, 186, 180, 180, 180, 186, 180, 180, 180, 186, 180, 180, 180, 186, 181, 181, 181, 99, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 170, 170, 170, 6, 179, 179, 179, 101, 181, 181, 181, 168, 179, 179, 179, 168, 181, 181, 181, 99, 170, 170, 170, 6, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0), +"format": "RGBA8", +"height": 12, +"mipmaps": false, +"width": 12 +} + +[sub_resource type="ImageTexture" id="ImageTexture_alomt"] +image = SubResource("Image_2c5qw") + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_ev3il"] +content_margin_left = 7.0 +content_margin_top = 7.0 +content_margin_right = 7.0 +content_margin_bottom = 7.0 +texture = SubResource("ImageTexture_alomt") +texture_margin_left = 6.0 +texture_margin_top = 6.0 +texture_margin_right = 6.0 +texture_margin_bottom = 6.0 + +[sub_resource type="Image" id="Image_c2fg1"] +data = { +"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 7, 255, 255, 255, 19, 255, 255, 255, 19, 255, 255, 255, 7, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 19, 255, 255, 255, 21, 255, 255, 255, 21, 255, 255, 255, 19, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 19, 255, 255, 255, 21, 255, 255, 255, 21, 255, 255, 255, 19, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 7, 255, 255, 255, 19, 255, 255, 255, 19, 255, 255, 255, 6, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0), +"format": "RGBA8", +"height": 12, +"mipmaps": false, +"width": 12 +} + +[sub_resource type="ImageTexture" id="ImageTexture_lun2q"] +image = SubResource("Image_c2fg1") + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_lk88d"] +content_margin_left = 6.0 +content_margin_top = 0.0 +content_margin_right = 6.0 +content_margin_bottom = 0.0 +texture = SubResource("ImageTexture_lun2q") +texture_margin_left = 5.0 +texture_margin_top = 5.0 +texture_margin_right = 5.0 +texture_margin_bottom = 5.0 + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_4ui4a"] +content_margin_left = 6.0 +content_margin_top = 6.0 +content_margin_right = 6.0 +content_margin_bottom = 6.0 +texture = SubResource("ImageTexture_lun2q") +texture_margin_left = 5.0 +texture_margin_top = 5.0 +texture_margin_right = 5.0 +texture_margin_bottom = 5.0 + +[resource] +default_font = ExtResource("5_p4ppy") +default_font_size = 18 +ConsoleColors/colors/entry_command_found_color = Color(0.729412, 0.901961, 0.494118, 1) +ConsoleColors/colors/entry_command_not_found_color = Color(1, 0.2, 0.2, 1) +ConsoleColors/colors/entry_hint_color = Color(0.439216, 0.478431, 0.54902, 1) +ConsoleColors/colors/entry_subcommand_color = Color(0.584314, 0.901961, 0.796078, 1) +ConsoleColors/colors/entry_text_color = Color(0.796078, 0.8, 0.776471, 1) +ConsoleColors/colors/history_highlight_color = Color(0.317647, 0.364706, 0.439216, 1) +ConsoleColors/colors/output_command_color = Color(0.729412, 0.901961, 0.494118, 1) +ConsoleColors/colors/output_command_mention_color = Color(0.584314, 0.901961, 0.796078, 1) +ConsoleColors/colors/output_debug_color = Color(0.439216, 0.478431, 0.54902, 1) +ConsoleColors/colors/output_error_color = Color(1, 0.2, 0.2, 1) +ConsoleColors/colors/output_text_color = Color(0.796078, 0.8, 0.776471, 1) +ConsoleColors/colors/output_warning_color = Color(1, 0.654902, 0.34902, 1) +Panel/styles/panel = SubResource("StyleBoxFlat_yia2g") +PanelContainer/styles/panel = SubResource("StyleBoxFlat_yia2g") +RichTextLabel/fonts/bold_font = ExtResource("1_cry2i") +RichTextLabel/fonts/bold_italics_font = ExtResource("2_h3f73") +RichTextLabel/fonts/italics_font = ExtResource("3_2qq11") +RichTextLabel/fonts/mono_font = ExtResource("4_06w3f") +RichTextLabel/fonts/normal_font = ExtResource("5_p4ppy") +RichTextLabel/styles/focus = SubResource("StyleBoxEmpty_bt363") +RichTextLabel/styles/normal = SubResource("StyleBoxEmpty_txn37") +TextEdit/styles/focus = SubResource("StyleBoxFlat_ypc4n") +TextEdit/styles/normal = SubResource("StyleBoxFlat_ypc4n") +VScrollBar/styles/grabber = SubResource("StyleBoxTexture_xismo") +VScrollBar/styles/grabber_highlight = SubResource("StyleBoxTexture_oajxf") +VScrollBar/styles/grabber_pressed = SubResource("StyleBoxTexture_ev3il") +VScrollBar/styles/scroll = SubResource("StyleBoxTexture_lk88d") +VScrollBar/styles/scroll_focus = SubResource("StyleBoxTexture_4ui4a") diff --git a/addons/limbo_console/res/fonts/LICENSE.md b/addons/limbo_console/res/fonts/LICENSE.md new file mode 100644 index 0000000..8f61f03 --- /dev/null +++ b/addons/limbo_console/res/fonts/LICENSE.md @@ -0,0 +1,93 @@ +Copyright (c) 2023, GitHub https://github.com/githubnext/monaspace +with Reserved Font Name "Monaspace", including subfamilies: "Argon", "Neon", "Xenon", "Radon", and "Krypton" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/addons/limbo_console/res/fonts/monaspace_argon_bold.otf b/addons/limbo_console/res/fonts/monaspace_argon_bold.otf new file mode 100644 index 0000000..7878b88 Binary files /dev/null and b/addons/limbo_console/res/fonts/monaspace_argon_bold.otf differ diff --git a/addons/limbo_console/res/fonts/monaspace_argon_bold.otf.import b/addons/limbo_console/res/fonts/monaspace_argon_bold.otf.import new file mode 100644 index 0000000..59eea51 --- /dev/null +++ b/addons/limbo_console/res/fonts/monaspace_argon_bold.otf.import @@ -0,0 +1,35 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://dbbw833hg2v7o" +path="res://.godot/imported/monaspace_argon_bold.otf-5d916059e7810f56d30161557f70d71b.fontdata" + +[deps] + +source_file="res://addons/limbo_console/res/fonts/monaspace_argon_bold.otf" +dest_files=["res://.godot/imported/monaspace_argon_bold.otf-5d916059e7810f56d30161557f70d71b.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/limbo_console/res/fonts/monaspace_argon_bold_italic.otf b/addons/limbo_console/res/fonts/monaspace_argon_bold_italic.otf new file mode 100644 index 0000000..90423e1 Binary files /dev/null and b/addons/limbo_console/res/fonts/monaspace_argon_bold_italic.otf differ diff --git a/addons/limbo_console/res/fonts/monaspace_argon_bold_italic.otf.import b/addons/limbo_console/res/fonts/monaspace_argon_bold_italic.otf.import new file mode 100644 index 0000000..f3d336a --- /dev/null +++ b/addons/limbo_console/res/fonts/monaspace_argon_bold_italic.otf.import @@ -0,0 +1,35 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://dmeyp84repfbw" +path="res://.godot/imported/monaspace_argon_bold_italic.otf-cd05eebec36875096d59dc2e6dfb87db.fontdata" + +[deps] + +source_file="res://addons/limbo_console/res/fonts/monaspace_argon_bold_italic.otf" +dest_files=["res://.godot/imported/monaspace_argon_bold_italic.otf-cd05eebec36875096d59dc2e6dfb87db.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/limbo_console/res/fonts/monaspace_argon_italic.otf b/addons/limbo_console/res/fonts/monaspace_argon_italic.otf new file mode 100644 index 0000000..8420390 Binary files /dev/null and b/addons/limbo_console/res/fonts/monaspace_argon_italic.otf differ diff --git a/addons/limbo_console/res/fonts/monaspace_argon_italic.otf.import b/addons/limbo_console/res/fonts/monaspace_argon_italic.otf.import new file mode 100644 index 0000000..032fcb9 --- /dev/null +++ b/addons/limbo_console/res/fonts/monaspace_argon_italic.otf.import @@ -0,0 +1,35 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://dhm45nttm5i3s" +path="res://.godot/imported/monaspace_argon_italic.otf-69d64783adde526699a99a191cd14ed6.fontdata" + +[deps] + +source_file="res://addons/limbo_console/res/fonts/monaspace_argon_italic.otf" +dest_files=["res://.godot/imported/monaspace_argon_italic.otf-69d64783adde526699a99a191cd14ed6.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/limbo_console/res/fonts/monaspace_argon_medium.otf b/addons/limbo_console/res/fonts/monaspace_argon_medium.otf new file mode 100644 index 0000000..deeb96d Binary files /dev/null and b/addons/limbo_console/res/fonts/monaspace_argon_medium.otf differ diff --git a/addons/limbo_console/res/fonts/monaspace_argon_medium.otf.import b/addons/limbo_console/res/fonts/monaspace_argon_medium.otf.import new file mode 100644 index 0000000..a18d031 --- /dev/null +++ b/addons/limbo_console/res/fonts/monaspace_argon_medium.otf.import @@ -0,0 +1,35 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://ds7dvyquauqub" +path="res://.godot/imported/monaspace_argon_medium.otf-2c090420a59a2bfcd1b6b28a7c3469ad.fontdata" + +[deps] + +source_file="res://addons/limbo_console/res/fonts/monaspace_argon_medium.otf" +dest_files=["res://.godot/imported/monaspace_argon_medium.otf-2c090420a59a2bfcd1b6b28a7c3469ad.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/limbo_console/res/fonts/monaspace_argon_regular.otf b/addons/limbo_console/res/fonts/monaspace_argon_regular.otf new file mode 100644 index 0000000..b94372a Binary files /dev/null and b/addons/limbo_console/res/fonts/monaspace_argon_regular.otf differ diff --git a/addons/limbo_console/res/fonts/monaspace_argon_regular.otf.import b/addons/limbo_console/res/fonts/monaspace_argon_regular.otf.import new file mode 100644 index 0000000..1a4c684 --- /dev/null +++ b/addons/limbo_console/res/fonts/monaspace_argon_regular.otf.import @@ -0,0 +1,35 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://d4js20k8kslqt" +path="res://.godot/imported/monaspace_argon_regular.otf-7622e0becc7f9143f21c9951dd015f30.fontdata" + +[deps] + +source_file="res://addons/limbo_console/res/fonts/monaspace_argon_regular.otf" +dest_files=["res://.godot/imported/monaspace_argon_regular.otf-7622e0becc7f9143f21c9951dd015f30.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/limbo_console/util.gd b/addons/limbo_console/util.gd new file mode 100644 index 0000000..72b1506 --- /dev/null +++ b/addons/limbo_console/util.gd @@ -0,0 +1,119 @@ +extends Object +## Utility functions + + +static func bbcode_escape(p_text: String) -> String: + return p_text \ + .replace("[", "~LB~") \ + .replace("]", "~RB~") \ + .replace("~LB~", "[lb]") \ + .replace("~RB~", "[rb]") + + +static func bbcode_strip(p_text: String) -> String: + var stripped := "" + var in_brackets: bool = false + for c: String in p_text: + if c == '[': + in_brackets = true + elif c == ']': + in_brackets = false + elif not in_brackets: + stripped += c + return stripped + + +static func get_method_info(p_callable: Callable) -> Dictionary: + var method_info: Dictionary + var method_list: Array[Dictionary] + if p_callable.get_object() is GDScript: + method_list = p_callable.get_object().get_script_method_list() + else: + method_list = p_callable.get_object().get_method_list() + for m in method_list: + if m.name == p_callable.get_method(): + method_info = m + break + if !method_info and p_callable.is_custom(): + var args: Array + var default_args: Array + for i in p_callable.get_argument_count(): + var argument: Dictionary + argument["name"] = "arg%d" % i + argument["type"] = TYPE_NIL + args.push_back(argument) + method_info["name"] = "" + method_info["args"] = args + method_info["default_args"] = default_args + return method_info + + +## Finds the most similar string in an array. +static func fuzzy_match_string(p_string: String, p_max_edit_distance: int, p_array) -> String: + if typeof(p_array) < TYPE_ARRAY: + push_error("LimboConsole: Internal error: p_array is not an array") + return "" + if p_array.size() == 0: + return "" + var best_distance: int = 9223372036854775807 + var best_match: String = "" + for i in p_array.size(): + var elem := str(p_array[i]) + var dist: float = _calculate_osa_distance(p_string, elem) + if dist < best_distance: + best_distance = dist + best_match = elem + return best_match if best_distance <= p_max_edit_distance else "" + + +## Calculates optimal string alignment distance [br] +## See: https://en.wikipedia.org/wiki/Levenshtein_distance +static func _calculate_osa_distance(s1: String, s2: String) -> int: + var s1_len: int = s1.length() + var s2_len: int = s2.length() + + # Iterative approach with 3 matrix rows. + # Most of the work is done on row1 and row2 - row0 is only needed to calculate transposition cost. + var row0: PackedInt32Array # previous-previous + var row1: PackedInt32Array # previous + var row2: PackedInt32Array # current aka the one we need to calculate + row0.resize(s2_len + 1) + row1.resize(s2_len + 1) + row2.resize(s2_len + 1) + + # edit distance is the number of characters to insert to get from empty string to s2 + for i in range(s2_len + 1): + row1[i] = i + + for i in range(s1_len): + # edit distance is the number of characters to delete from s1 to match empty s2 + row2[0] = i + 1 + + for j in range(s2_len): + var deletion_cost: int = row1[j + 1] + 1 + var insertion_cost: int = row2[j] + 1 + var substitution_cost: int = row1[j] if s1[i] == s2[j] else row1[j] + 1 + + row2[j + 1] = min(deletion_cost, insertion_cost, substitution_cost) + + if i > 1 and j > 1 and s1[i - 1] == s2[j] and s1[i - 1] == s2[j]: + var transposition_cost: int = row0[j - 1] + 1 + row2[j + 1] = mini(transposition_cost, row2[j + 1]) + + # Swap rows. + var tmp: PackedInt32Array = row0 + row0 = row1 + row1 = row2 + row2 = tmp + return row1[s2_len] + + +## Returns true, if a string is constructed of one or more space-separated valid +## command identifiers ("command" or "command sub1 sub2"). +## A valid command identifier may contain only letters, digits, and underscores (_), +## and the first character may not be a digit. +static func is_valid_command_sequence(p_string: String) -> bool: + for part in p_string.split(' '): + if not part.is_valid_ascii_identifier(): + return false + return true diff --git a/addons/limbo_console/util.gd.uid b/addons/limbo_console/util.gd.uid new file mode 100644 index 0000000..00e8f18 --- /dev/null +++ b/addons/limbo_console/util.gd.uid @@ -0,0 +1 @@ +uid://cw6s1es6yjip5 diff --git a/project.godot b/project.godot index 867b8e1..1efa6e9 100644 --- a/project.godot +++ b/project.godot @@ -21,6 +21,7 @@ config/icon="uid://dihjvrhshhklu" [autoload] GameBus="*res://Scripts/Singletons/GameBus.cs" +LimboConsole="*res://addons/limbo_console/limbo_console.gd" [display] @@ -30,10 +31,32 @@ window/stretch/mode="canvas_items" project/assembly_name="ParasiticGod" +[editor_plugins] + +enabled=PackedStringArray("res://addons/limbo_console/plugin.cfg") + [gui] theme/custom_font="uid://b8tf6dbm2j0ju" +[input] + +limbo_console_toggle={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":96,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +limbo_auto_complete_reverse={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +limbo_console_search_history={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} + [rendering] renderer/rendering_method="gl_compatibility"