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 @@
+
+
+
+
+---
+
+
+
+[](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.
+
+[](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"