diff --git a/Debug/CharIterator.cs b/Debug/CharIterator.cs new file mode 100644 index 0000000..124cfc1 --- /dev/null +++ b/Debug/CharIterator.cs @@ -0,0 +1,29 @@ +namespace SupaLidlGame.Debug; + +internal sealed class CharIterator : Iterator +{ + public CharIterator(string str) : base(str.ToCharArray()) + { + + } + + public CharIterator(char[] chars) : base(chars) + { + + } + + public override char MoveNext() + { + char c = base.MoveNext(); + if (c == '\n') + { + Line++; + Column = 0; + } + else + { + Column++; + } + return c; + } +} diff --git a/Debug/DebugConsole.cs b/Debug/DebugConsole.cs index 585582f..b0ae227 100644 --- a/Debug/DebugConsole.cs +++ b/Debug/DebugConsole.cs @@ -2,21 +2,97 @@ using Godot; namespace SupaLidlGame.Debug; -public partial class DebugConsole : Node +public sealed partial class DebugConsole : Control { - public void SetProp( - Utils.World world, - string entityName, - string property, - string value) + private Node _context; + public Node Context { - var ent = world.CurrentMap.Entities.GetNodeOrNull(entityName); - if (ent is not null) + get => _context; + private set { - ent.Set(property, value); + if (value is not null) + { + _context = value; + if (_entry is not null) + { + _entry.PlaceholderText = "Enter Godot expression from " + + _context.GetPath(); + GetParent().Title = "Supa Developer Console: " + + _context.GetPath(); + } + } } } + private Entry _entry; + + private RichTextLabel _output; + + private Node ctx => Context; + + public override void _Ready() + { + _entry = GetNode("%Entry"); + _output = GetNode("%Output"); + Context = Utils.World.Instance; + + _entry.ConsoleInput += (string input) => + { + try + { + Execute(input); + } + catch (InterpreterException ex) + { + _output.Text += ex.Message + '\n'; + } + }; + } + + enum PathType + { + Node, + Property + }; + + public Variant From(NodePath path) + { + CharIterator iterator = new(path); + Variant variant = Context ?? this; + foreach (var subpath in NodePathParser.ParseNodePath(iterator)) + { + if (variant.VariantType == Variant.Type.Object) + { + if (variant.AsGodotObject() is Node n) + { + if (subpath.Type == NodePathTokenType.Node) + { + if (subpath.Path != "") + { + variant = n.GetNode(subpath.Path); + } + } + else + { + variant = n.GetIndexed(subpath.Path); + } + } + } + } + + return variant; + } + + public void SetProp(NodePath path, Variant value) + { + var node = GetNode(path.GetAsPropertyPath()); + //var ent = CurrentMap.Entities.GetNodeOrNull(entityName); + //if (ent is not null) + //{ + // ent.Set(property, value); + //} + } + public string CallMethod( Utils.World world, string entityName, @@ -31,4 +107,54 @@ public partial class DebugConsole : Node } return ""; } + + public void Print(string text) + { + GD.Print(text); + } + + public void Execute(string str) + { + str = Sanitizer.Sanitize(str); + string inputMirror = $"[b]{Context.GetPath()}:[/b] {str}"; + _output.Text += inputMirror + "\n"; + var context = Context; + + Godot.Expression exp = new(); + + string[] reserved = { "from", "set_context", "context" }; + Godot.Collections.Array reservedMap = new(); + reservedMap.Add(new Callable(this, MethodName.From)); + reservedMap.Add(new Callable(this, MethodName.SetContext)); + reservedMap.Add(Context); + + var err = exp.Parse(str, reserved); + if (err != Error.Ok) + { + throw new InterpreterException( + "Error occurred while parsing Godot.Expression: \n" + + exp.GetErrorText(), + 0, 0); + } + + Variant result = exp.Execute(reservedMap, context); + if (exp.HasExecuteFailed()) + { + throw new InterpreterException( + "Error occurred while evaluating Godot.Expression: \n" + + exp.GetErrorText(), + 0, 0); + } + + // send result to output + if (result.VariantType != Variant.Type.Nil) + { + _output.Text += result + "\n"; + } + } + + public void SetContext(Node node) + { + Context = node; + } } diff --git a/Debug/Entry.cs b/Debug/Entry.cs new file mode 100644 index 0000000..323bee6 --- /dev/null +++ b/Debug/Entry.cs @@ -0,0 +1,30 @@ +using Godot; + +namespace SupaLidlGame.Debug; + +public partial class Entry : LineEdit +{ + [Signal] + public delegate void ConsoleInputEventHandler(string input); + + public override void _Ready() + { + GuiInput += OnGuiInput; + } + + public void OnGuiInput(InputEvent @event) + { + if (@event is InputEventKey key) + { + if (key.KeyLabel == Key.Enter && !key.Pressed) + { + EmitSignal(SignalName.ConsoleInput, Text); + + if (!key.CtrlPressed) + { + Text = ""; + } + } + } + } +} diff --git a/Debug/Iterator.cs b/Debug/Iterator.cs new file mode 100644 index 0000000..0e258f3 --- /dev/null +++ b/Debug/Iterator.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace SupaLidlGame.Debug; + +public class Iterator where T : struct +{ + public int Line { get; protected set; } = 1; + + public int Column { get; protected set; } = 0; + + public int Index { get; protected set; } = -1; + + protected List _elements; + + public Iterator(T[] elements) + { + _elements = new List(elements); + } + + public Iterator(List elements) + { + _elements = new List(elements); + } + + public T GetNext(int offset = 0) + { + if (Index + offset + 1 < _elements.Count) + { + return _elements[Index + offset + 1]; + } + + return default; + } + + public virtual T MoveNext() + { + T next = GetNext(); + Index++; + return next; + } + + public virtual void MoveBack() + { + Index--; + } +} diff --git a/Debug/NodePathParser.cs b/Debug/NodePathParser.cs new file mode 100644 index 0000000..d37725c --- /dev/null +++ b/Debug/NodePathParser.cs @@ -0,0 +1,65 @@ +using Godot; +using System.Collections.Generic; + +namespace SupaLidlGame.Debug; + +internal static class NodePathParser +{ + internal static IEnumerable ParseNodePath(CharIterator iterator) + { + // Some/Node/Path:And:Property/More/Paths + // -> + // Some/Node/Path (Node) + // :And:Property (Property) + // More/Paths (Node) + + NodePathTokenType curType = NodePathTokenType.Node; + string path = ""; + while (iterator.GetNext() != '\0') + { + char curChar = iterator.MoveNext(); + + if (curChar == ':') + { + // if we have been parsing a nodepath, yield a nodepath + if (curType == NodePathTokenType.Node) + { + if (path.Length > 0) + { + yield return new NodePathToken(path, curType); + path = ""; + curType = NodePathTokenType.Property; + } + } + else + { + path += curChar; + } + } + else if (curChar == '/') + { + // if we have been parsing property, yield a property + if (curType == NodePathTokenType.Property) + { + yield return new NodePathToken(path, curType); + path = ""; + curType = NodePathTokenType.Node; + } + else + { + path += curChar; + } + } + else + { + path += curChar; + } + } + + // reached the end + if (path.Length > 0) + { + yield return new NodePathToken(path, curType); + } + } +} diff --git a/Debug/NodePathToken.cs b/Debug/NodePathToken.cs new file mode 100644 index 0000000..f5b0a2a --- /dev/null +++ b/Debug/NodePathToken.cs @@ -0,0 +1,20 @@ +using Godot; + +public enum NodePathTokenType +{ + Node, + Property +} + +public struct NodePathToken +{ + public NodePath Path { get; set; } + + public NodePathTokenType Type { get; set; } + + public NodePathToken(NodePath path, NodePathTokenType type) + { + Path = path; + Type = type; + } +} diff --git a/Debug/Sanitizer.cs b/Debug/Sanitizer.cs new file mode 100644 index 0000000..4741ada --- /dev/null +++ b/Debug/Sanitizer.cs @@ -0,0 +1,136 @@ +using Godot; +using System.Text.RegularExpressions; + +namespace SupaLidlGame.Debug; + +public static class Sanitizer +{ + private static Regex _nonAlphanum = new("[^a-zA-Z0-9_]"); + + private static Regex _nonNodeName = new("[^a-zA-Z0-9_\\-\\/]"); + + private static string ScanString(CharIterator iterator) + { + string ret = ""; + + while (iterator.GetNext() != '\0') + { + char c = iterator.MoveNext(); + + if (c == '"') + { + return ret; + } + else if (c == '\\') + { + char escape = iterator.MoveNext(); + + switch (escape) + { + case 'n': + ret += '\n'; + break; + case 't': + ret += '\t'; + break; + case '\0': + throw new InterpreterException("Unexpected EOL", + iterator.Line, iterator.Column); default: + ret += escape; + break; + } + } + + ret += c; + } + throw new InterpreterException("Unexpected EOL, expected '\"'", + iterator.Line, iterator.Column); + } + + private static string ScanNodePath(CharIterator iterator) + { + string ret = ""; + while (iterator.GetNext() != '\0') + { + char c = iterator.MoveNext(); + + if (c == '"') + { + return ScanString(iterator); + } + else if (_nonNodeName.IsMatch(c.ToString())) + { + iterator.MoveBack(); + return ret; + } + + ret += c; + } + return ret; + } + + private static string ScanUntilOrEOL(CharIterator iterator, char delim) + { + string ret = ""; + while (iterator.GetNext() != '\0') + { + char c = iterator.GetNext(); + if (c == delim) + { + return ret; + } + ret += c; + } + return ret; + } + + private static string ScanGlobalCommand(CharIterator iterator) + { + string ret = ""; + while (iterator.GetNext() != '\0') + { + char c = iterator.MoveNext(); + if (_nonAlphanum.IsMatch(c.ToString())) + { + iterator.MoveBack(); + return ret; + } + ret += c; + } + return ret; + } + + public static string Sanitize(string input) + { + CharIterator iterator = new(input); + string ret = ""; + + while (iterator.GetNext() != '\0') + { + char c = iterator.MoveNext(); + + if (c == '$') + { + string nodePath = ScanNodePath(iterator); + ret += $"from.call(\"{nodePath}\")"; + } + else if (c == '"') + { + string str = ScanString(iterator); + ret += $"\"{str}\""; + } + else if (c == '\\') + { + // \global -> global.call + string command = ScanGlobalCommand(iterator); + ret += $"{command}.call"; + } + else + { + ret += c; + } + } + + return ret; + } +} diff --git a/Debug/Token.cs b/Debug/Token.cs new file mode 100644 index 0000000..6a52e93 --- /dev/null +++ b/Debug/Token.cs @@ -0,0 +1,49 @@ +namespace SupaLidlGame.Debug; + +public enum TokenType +{ + None, + Identifier, + String, + GodotExpression, + Command, + End +}; + +public struct Token +{ + public TokenType Type { get; set; } + + public string Value { get; set; } + + public int Line { get; set; } + + public int Column { get; set; } + + public Token(TokenType type, string value, int line, int col) + { + Type = type; + Value = value; + Line = line; + Column = col; + } + + public bool CompareTypeValue(Token token) + { + return Type == token.Type && Value == token.Value; + } + + public override bool Equals(object obj) + { + return base.Equals(obj); + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } + + public static bool operator ==(Token left, Token right) => left.Equals(right); + + public static bool operator !=(Token left, Token right) => !left.Equals(right); +} diff --git a/Debug/Tokenizer.cs b/Debug/Tokenizer.cs new file mode 100644 index 0000000..4dcfd1d --- /dev/null +++ b/Debug/Tokenizer.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; + +namespace SupaLidlGame.Debug; + +internal sealed class Tokenizer +{ + private static readonly HashSet WHITESPACE = new HashSet { ' ', '\n' }; + + private static string ScanString(CharIterator iterator) + { + string ret = ""; + while (iterator.GetNext() != '\0') + { + char c = iterator.MoveNext(); + + if (c == '"') + { + return ret; + } + else if (c == '\\') + { + char escape = iterator.MoveNext(); + + switch (escape) + { + case 'n': + ret += '\n'; + break; + case 't': + ret += '\t'; + break; + case '\0': + throw new InterpreterException("Unexpected EOL", + iterator.Line, iterator.Column); + default: + ret += escape; + break; + } + } + + ret += c; + } + throw new InterpreterException("Unexpected EOL, expected '\"'", + iterator.Line, iterator.Column); + } + + private static string ScanNodePath(CharIterator iterator) + { + string ret = ""; + while (iterator.GetNext() != '\0') + { + char c = iterator.GetNext(); + + if (c == '"') + { + ret += ScanString(iterator); + } + else if (WHITESPACE.Contains(c)) + { + return ret; + } + + ret += c; + } + return ret; + } + + /* + private static string ScanUntil(CharIterator iterator) + { + + } + */ + + private static string ScanExpression(CharIterator iterator) + { + int level = 0; + string exp = ""; + while (iterator.GetNext() != '\0') + { + char c = iterator.GetNext(); + + if (c == '(') + { + level++; + } + else if (c == ')') + { + level--; + } + + if (level < 0) + { + return exp; + } + + exp += c; + } + return exp; + } + + private static string ScanUntilOrEOL(CharIterator iterator, char delim) + { + string ret = ""; + while (iterator.GetNext() != '\0') + { + char c = iterator.GetNext(); + if (c == delim) + { + return ret; + } + ret += c; + } + return ret; + } + + public static IEnumerable Tokenize(CharIterator iterator) + { + System.Diagnostics.Debug.Print("hi"); + while (iterator.GetNext() != '\0') + { + char curChar = iterator.MoveNext(); + System.Diagnostics.Debug.Print(curChar.ToString()); + + int line = iterator.Line; + int col = iterator.Column; + + if (WHITESPACE.Contains(curChar)) + { + continue; + } + else if (curChar == '\\') + { + string command = ScanUntilOrEOL(iterator, ' '); + if (command == "") + { + throw new InterpreterException( + "Expected a command name", + iterator.Line, + iterator.Column); + } + yield return new Token(TokenType.Command, + command, + line, + col); + } + else if (curChar == '(') + { + string exp = ScanExpression(iterator); + yield return new Token(TokenType.GodotExpression, + exp, + line, + col); + } + else if (curChar == '"') + { + yield return new Token(TokenType.String, + ScanString(iterator), + line, + col); + } + else + { + // parse this as expression + string exp = ScanUntilOrEOL(iterator, ' '); + yield return new Token(TokenType.GodotExpression, + exp, + line, + col); + } + /* + else if (curChar == '$') + { + yield return new Token(TokenType.NodePath, + ScanNodePath(iterator), + line, + col); + } + */ + } + yield return new Token(TokenType.End, "", + iterator.Line, iterator.Column); + } +} + diff --git a/Exceptions/InterpreterException.cs b/Exceptions/InterpreterException.cs new file mode 100644 index 0000000..244fa00 --- /dev/null +++ b/Exceptions/InterpreterException.cs @@ -0,0 +1,13 @@ +namespace SupaLidlGame; + +public class InterpreterException : System.Exception +{ + public int Line { get; set; } + public int Column { get; set; } + + public InterpreterException(string msg, int line, int column) : base(msg) + { + Line = line; + Column = column; + } +} diff --git a/Extensions/Node.cs b/Extensions/Node.cs index 34f47b6..757d6ee 100644 --- a/Extensions/Node.cs +++ b/Extensions/Node.cs @@ -4,28 +4,6 @@ namespace SupaLidlGame.Extensions; public static class NodeExtensions { - /// - /// Iterates through each ancestor until it finds an ancestor of type - /// T - /// - [System.Obsolete] - public static T GetAncestorDeprecated(this Node node) where T : Node - { - Node parent; - - while ((parent = node.GetParent()) != null) - { - if (parent is T t) - { - return t; - } - - node = parent; - } - - return null; - } - /// /// A version GetNode that returns null rather than cause an /// exception if the node is not found or is not the same type. diff --git a/UI/Base.tscn b/UI/Base.tscn index bb363a1..fadda34 100644 --- a/UI/Base.tscn +++ b/UI/Base.tscn @@ -29,6 +29,7 @@ stretch = true stretch_shrink = 3 [node name="UIViewport" type="SubViewport" parent="SubViewportContainer"] +disable_3d = true transparent_bg = true handle_input_locally = false size = Vector2i(640, 360) diff --git a/UI/Debug/DebugUI.tscn b/UI/Debug/DebugUI.tscn index cf880c7..1d347f7 100644 --- a/UI/Debug/DebugUI.tscn +++ b/UI/Debug/DebugUI.tscn @@ -1,4 +1,7 @@ -[gd_scene format=3 uid="uid://be8bc4eivsg4s"] +[gd_scene load_steps=3 format=3 uid="uid://be8bc4eivsg4s"] + +[ext_resource type="Script" path="res://Debug/DebugConsole.cs" id="1_3fw5a"] +[ext_resource type="Script" path="res://Debug/Entry.cs" id="2_kdlsh"] [node name="DebugUI" type="Control"] layout_mode = 3 @@ -8,11 +11,47 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -[node name="VBoxContainer" type="VBoxContainer" parent="."] -layout_mode = 1 -anchors_preset = 12 -anchor_top = 1.0 +[node name="Window" type="Window" parent="."] +disable_3d = true +gui_embed_subwindows = true +title = "Supa Developer Console" +position = Vector2i(32, 32) +size = Vector2i(1280, 720) +always_on_top = true + +[node name="DebugConsole" type="Control" parent="Window"] +layout_mode = 3 +anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 -grow_vertical = 0 +grow_vertical = 2 +script = ExtResource("1_3fw5a") + +[node name="VBoxContainer" type="VBoxContainer" parent="Window/DebugConsole"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Output" type="RichTextLabel" parent="Window/DebugConsole/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +theme_override_font_sizes/normal_font_size = 24 +theme_override_font_sizes/bold_font_size = 24 +bbcode_enabled = true +text = "[b]/root/World:[/b] \\echo :CurrentPlayer:Health +100 +" +scroll_following = true + +[node name="Entry" type="LineEdit" parent="Window/DebugConsole/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 24 +placeholder_text = "Enter a GDScript expression or \\command..." +draw_control_chars = true +script = ExtResource("2_kdlsh") diff --git a/project.godot b/project.godot index c4a8c1b..0fd601d 100644 --- a/project.godot +++ b/project.godot @@ -23,8 +23,8 @@ EventBus="*res://Events/EventBus.cs" BaseUI="*res://UI/Base.tscn" World="*res://Scenes/Level.tscn" AudioManager="*res://Audio/AudioManager.cs" -DebugConsole="*res://Debug/DebugConsole.cs" Panku="*res://addons/panku_console/console.tscn" +DebugUi="*res://UI/Debug/DebugUI.tscn" [dialogue_manager]