diff --git a/Scenes/Enemy.tscn b/Scenes/Enemy.tscn index 33471e2b..3ab60a92 100644 --- a/Scenes/Enemy.tscn +++ b/Scenes/Enemy.tscn @@ -1,8 +1,9 @@ -[gd_scene load_steps=8 format=3 uid="uid://v8s3kubgb2qg"] +[gd_scene load_steps=9 format=3 uid="uid://v8s3kubgb2qg"] [ext_resource type="Texture2D" uid="uid://b4ynnb14mb4uq" path="res://Sprites/Reisen.png" id="1_4w8mj"] [ext_resource type="Script" path="res://Scripts/Enemy.cs" id="1_lpwdj"] [ext_resource type="PackedScene" uid="uid://cuixq5ex0j40h" path="res://Scenes/enemyBullet.tscn" id="2_ogldd"] +[ext_resource type="PackedScene" uid="uid://crry0rgk7a8sm" path="res://Scenes/Weapons/BaseWeapon.tscn" id="4_2k1dv"] [sub_resource type="AtlasTexture" id="AtlasTexture_2brqc"] atlas = ExtResource("1_4w8mj") @@ -17,12 +18,11 @@ radius = 4.0 [sub_resource type="CircleShape2D" id="CircleShape2D_v711r"] radius = 85.0529 -[node name="Enemy" type="Area2D" groups=["Destroyable"]] +[node name="Enemy" type="Area2D" node_paths=PackedStringArray("EquippedWeapon") groups=["Destroyable"]] collision_layer = 16 collision_mask = 9 script = ExtResource("1_lpwdj") -BulletScene = ExtResource("2_ogldd") -BulletSpeed = 50.0 +EquippedWeapon = NodePath("Weapon") metadata/_edit_group_ = true [node name="Sprite2D" type="Sprite2D" parent="."] @@ -48,6 +48,11 @@ shape = SubResource("CircleShape2D_v711r") wait_time = 0.4 one_shot = true +[node name="Weapon" parent="." instance=ExtResource("4_2k1dv")] +BulletScene = ExtResource("2_ogldd") +BulletCapacity = 4 +BulletSpeed = 50.0 + [connection signal="area_entered" from="." to="." method="_on_area_entered"] [connection signal="area_entered" from="PlayerDetection" to="." method="_on_player_detection_area_entered"] [connection signal="area_exited" from="PlayerDetection" to="." method="_on_player_detection_area_exited"] diff --git a/Scenes/Weapons/BaseWeapon.tscn b/Scenes/Weapons/BaseWeapon.tscn new file mode 100644 index 00000000..61cab89d --- /dev/null +++ b/Scenes/Weapons/BaseWeapon.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3 uid="uid://crry0rgk7a8sm"] + +[ext_resource type="Script" path="res://Scripts/Weapon.cs" id="1_f5iec"] + +[node name="Weapon" type="Sprite2D"] +script = ExtResource("1_f5iec") + +[node name="Muzzle" type="Marker2D" parent="."] +position = Vector2(5, 0) + +[node name="ShootTimer" type="Timer" parent="."] +one_shot = true diff --git a/Scenes/player.tscn b/Scenes/player.tscn index a88e208c..9f927856 100644 --- a/Scenes/player.tscn +++ b/Scenes/player.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=27 format=3 uid="uid://bghghp5ep4w2j"] +[gd_scene load_steps=28 format=3 uid="uid://bghghp5ep4w2j"] [ext_resource type="Script" path="res://Scripts/PlayerMovement.cs" id="1_m27vu"] [ext_resource type="Texture2D" uid="uid://la06powu57hu" path="res://Sprites/Cirno_Big.png" id="2_bwf6x"] @@ -8,6 +8,7 @@ [ext_resource type="Script" path="res://Scenes/CameraTarget.gd" id="5_cxvyt"] [ext_resource type="PackedScene" uid="uid://cfb3nsay84xdb" path="res://Scenes/crosshair.tscn" id="6_l43rf"] [ext_resource type="Script" path="res://Scenes/InteractionController.cs" id="7_uvgjg"] +[ext_resource type="PackedScene" uid="uid://crry0rgk7a8sm" path="res://Scenes/Weapons/BaseWeapon.tscn" id="9_wblq0"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_ai4rh"] size = Vector2(6, 6) @@ -141,7 +142,7 @@ radius = 17.2627 [sub_resource type="CircleShape2D" id="CircleShape2D_e6woi"] radius = 1.41421 -[node name="Player" type="CharacterBody2D" node_paths=PackedStringArray("Muzzle") groups=["Destroyable", "player"]] +[node name="Player" type="CharacterBody2D" node_paths=PackedStringArray("Muzzle", "EquippedWeapon") groups=["Destroyable", "player"]] collision_layer = 2 collision_mask = 99 script = ExtResource("1_m27vu") @@ -152,7 +153,7 @@ SelectorScene = ExtResource("3_8wt6s") GameOverScene = "res://Scenes/GameOver.tscn" Muzzle = NodePath("Muzzle") Health = 32.0 -RateOfFire = 0.1 +EquippedWeapon = NodePath("Weapon") metadata/_edit_group_ = true [node name="CollisionShape2D" type="CollisionShape2D" parent="."] @@ -205,5 +206,12 @@ collision_mask = 128 shape = SubResource("CircleShape2D_e6woi") debug_color = Color(1, 0.00817797, 0.0443347, 0.42) +[node name="Weapon" parent="." instance=ExtResource("9_wblq0")] +BulletScene = ExtResource("2_ov36d") +RateOfFire = 0.1 +BulletCapacity = 100 +ReloadTime = 0.4 +BulletSpeed = 300.0 + [connection signal="area_entered" from="InteractionController" to="." method="_on_interaction_controller_area_entered"] [connection signal="area_entered" from="DamageHitBox" to="." method="_on_damage_hit_box_area_entered"] diff --git a/Scenes/test.tscn b/Scenes/test.tscn index d8708e6b..d8a6020a 100644 --- a/Scenes/test.tscn +++ b/Scenes/test.tscn @@ -1,7 +1,6 @@ -[gd_scene load_steps=19 format=4 uid="uid://bv451a8wgty4u"] +[gd_scene load_steps=18 format=4 uid="uid://bv451a8wgty4u"] [ext_resource type="PackedScene" uid="uid://bghghp5ep4w2j" path="res://Scenes/player.tscn" id="2_8mh54"] -[ext_resource type="PackedScene" uid="uid://cxmcqehjjy82j" path="res://Scenes/reisen.tscn" id="3_8k37m"] [ext_resource type="PackedScene" uid="uid://rp4jhx0tuh24" path="res://Scenes/fragola.tscn" id="4_s7wq6"] [ext_resource type="PackedScene" uid="uid://bj28qiai2x2ar" path="res://Scenes/Barrel.tscn" id="5_3uba3"] [ext_resource type="PackedScene" uid="uid://uaf5r6cd71hu" path="res://Scenes/Furniture/LargeTank.tscn" id="6_nkauc"] @@ -280,6 +279,7 @@ position = Vector2(-766, -74) [node name="CameraController" type="Camera2D" parent="."] script = ExtResource("6_t8ide") +pixel_snap = false [node name="Player" parent="." instance=ExtResource("2_8mh54")] position = Vector2(-790, -165) @@ -301,9 +301,6 @@ polygon = PackedVector2Array(95, 57, 46, 58, -83, 61, -91, -37, 88, -37, 114, -1 [node name="Fragola" parent="." instance=ExtResource("4_s7wq6")] position = Vector2(-743, -117) -[node name="CharacterBody2D" parent="." instance=ExtResource("3_8k37m")] -position = Vector2(78, -15) - [node name="Enemy" parent="." instance=ExtResource("18_ixcwn")] position = Vector2(-687, -10) diff --git a/Scripts/Enemy.cs b/Scripts/Enemy.cs index 6baf1cbe..aa940ed7 100644 --- a/Scripts/Enemy.cs +++ b/Scripts/Enemy.cs @@ -8,26 +8,19 @@ public partial class Enemy : Area2D, IDestructible private InteractionController _cachedPlayer; private EnemyState _currentState = EnemyState.Idle; - [Export] - public PackedScene BulletScene { get; set; } - [Export] public float Health = 4f; - [Export] public double RateOfFire = 0.4f; - - [Export] public float BulletSpeed = 100f; + [Export] public Weapon EquippedWeapon; private float _currentHealth = 0f; private bool _isDestroyed = false; - - private Timer _cooldownTimer; + // Called when the node enters the scene tree for the first time. public override void _Ready() { _currentHealth = Health; - _cooldownTimer = GetNode("./ShootTimer"); } // Called every frame. 'delta' is the elapsed time since the previous frame. @@ -64,20 +57,42 @@ public partial class Enemy : Area2D, IDestructible return; } - if (_cooldownTimer.IsStopped() && IsPlayerInSight()) + if (IsPlayerInSight()) { - // SHOOT - var bullet = this.CreateChild(BulletScene); - // var bullet = BulletScene.Instantiate(); - // Owner.AddChild(bullet); - // bullet.Transform = this.GlobalTransform; - // bullet.Position = this.Position; - bullet.SetDirection((_cachedPlayer.GlobalPosition - this.GlobalPosition).Normalized()); - bullet.Speed = BulletSpeed; - _cooldownTimer.Start(RateOfFire); + Shoot(); } } + private void Shoot() + { + if (EquippedWeapon == null) return; + + EquippedWeapon.ShootDirection = (_cachedPlayer.GlobalPosition - this.GlobalPosition).Normalized(); + + EquippedWeapon.Shoot(); + + // // SHOOT + // var bullet = this.CreateChild(BulletScene); + // // var bullet = BulletScene.Instantiate(); + // // Owner.AddChild(bullet); + // // bullet.Transform = this.GlobalTransform; + // // bullet.Position = this.Position; + // bullet.SetDirection((_cachedPlayer.GlobalPosition - this.GlobalPosition).Normalized()); + // bullet.Speed = BulletSpeed; + // + // _ammo -= 1; + // + // if (_ammo <= 0) + // { + // _ammo = BulletCount; + // _cooldownTimer.Start(ReloadTime); + // } + // else + // { + // _cooldownTimer.Start(RateOfFire); + // } + } + private bool IsPlayerInSight() { var spaceState = GetWorld2D().DirectSpaceState; diff --git a/Scripts/MainMenu.cs b/Scripts/MainMenu.cs index 9e7151a2..1d040c01 100644 --- a/Scripts/MainMenu.cs +++ b/Scripts/MainMenu.cs @@ -9,7 +9,7 @@ public partial class MainMenu : Control [Export] public string MainMenuScene { get; set; } - + // Called when the node enters the scene tree for the first time. public override void _Ready() { diff --git a/Scripts/PlayerMovement.cs b/Scripts/PlayerMovement.cs index ebd4423f..c45e208b 100644 --- a/Scripts/PlayerMovement.cs +++ b/Scripts/PlayerMovement.cs @@ -11,9 +11,6 @@ public partial class PlayerMovement : CharacterBody2D, IDestructible [Export] public float CrosshairDistance { get; set; } = 10f; - [Export] - public PackedScene BulletScene { get; set; } - [Export] public PackedScene SelectorScene { get; set; } @@ -35,16 +32,13 @@ public partial class PlayerMovement : CharacterBody2D, IDestructible private Sprite2D _crosshair; - private Timer _cooldownTimer; - [Export] public float Health = 4f; + + [Export] public Weapon EquippedWeapon; + [DebugGUIPrint] private float _currentHealth = 0f; - [Export] public double RateOfFire = 0.4f; - - [Export] public float BulletSpeed = 300f; - private bool _isDestroyed = false; public override void _Ready() @@ -53,7 +47,6 @@ public partial class PlayerMovement : CharacterBody2D, IDestructible _animatedSprite = GetNode("./Smoothing2D/AnimatedSprite2D"); _crosshair = GetNode("./Smoothing2D/Crosshair"); - _cooldownTimer = GetNode("./ShootTimer"); _movementDirection = Vector2.Zero; _facingDirection = Vector2.Zero; @@ -99,13 +92,18 @@ public partial class PlayerMovement : CharacterBody2D, IDestructible private void HandleShoot() { - if (!Input.IsActionJustPressed("shoot")) return; - //Debug.WriteLine("Shoot"); - var bullet = BulletScene.Instantiate(); - Owner.AddChild(bullet); - bullet.Transform = Muzzle.GlobalTransform; - bullet.Position = this.Position; - bullet.SetDirection(this._facingDirection); + if (EquippedWeapon == null) return; + if (!Input.IsActionPressed("shoot")) return; + + EquippedWeapon.ShootDirection = this._facingDirection; + EquippedWeapon.Shoot(); + + // //Debug.WriteLine("Shoot"); + // var bullet = BulletScene.Instantiate(); + // Owner.AddChild(bullet); + // bullet.Transform = Muzzle.GlobalTransform; + // bullet.Position = this.Position; + // bullet.SetDirection(this._facingDirection); } private void SetAnimation() diff --git a/Scripts/Weapon.cs b/Scripts/Weapon.cs new file mode 100644 index 00000000..8c1e805d --- /dev/null +++ b/Scripts/Weapon.cs @@ -0,0 +1,84 @@ +using Godot; +using System; +using Cirno.Scripts; + +public partial class Weapon : Node2D +{ + + [Export] + public PackedScene BulletScene { get; set; } + + [Export] + public Marker2D Muzzle { get; set; } + + [Export] public double RateOfFire = 0.4f; + + [Export] public int BulletCapacity = 20; + + [Export] public double ReloadTime = 1.0f; + + [Export] public float BulletSpeed = 100f; + + [Export] public bool AutoReload = true; + + [Export] public bool InfiniteAmmo = true; + public int Ammo { get; set; } = 0; + + public int LoadedAmmo { get; private set; } + + public Vector2 ShootDirection { get; set; } = Vector2.Zero; + + private Timer _cooldownTimer; + + private Marker2D _muzzle; + + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + _muzzle = GetNode("./Muzzle"); + _cooldownTimer = GetNode("./ShootTimer"); + } + + public void Reload() + { + _cooldownTimer.Start(ReloadTime); + + if (InfiniteAmmo) + { + LoadedAmmo = BulletCapacity; + } + else + { + // TODO: Calculate subtraction, etc + LoadedAmmo = BulletCapacity; + } + } + + public void Shoot() + { + // Waiting on reload or Rate of Fire cooldown? + if (!_cooldownTimer.IsStopped()) + { + return; + } + + // Out of ammo? + if (LoadedAmmo <= 0) + { + if (AutoReload) + { + Reload(); + } + return; + } + + // TODO: Shoot at muzzle position, need to provide a way to turn it, on a radius? + var bullet = this.CreateChild(BulletScene); + bullet.SetDirection(ShootDirection); + bullet.Speed = BulletSpeed; + + LoadedAmmo -= 1; + + _cooldownTimer.Start(RateOfFire); + } +} diff --git a/addons/DebugGUI/Attributes/DebugGUIGraphAttribute.cs b/addons/DebugGUI/Attributes/DebugGUIGraphAttribute.cs new file mode 100644 index 00000000..6e0983d4 --- /dev/null +++ b/addons/DebugGUI/Attributes/DebugGUIGraphAttribute.cs @@ -0,0 +1,33 @@ +using Godot; +using System; + +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +public class DebugGUIGraphAttribute : Attribute +{ + public float min { get; private set; } + public float max { get; private set; } + public Color color { get; private set; } + public int group { get; private set; } + public bool autoScale { get; private set; } + + public DebugGUIGraphAttribute( + // Line color + float r = 1, + float g = 1, + float b = 1, + // Values at top/bottom of graph + float min = 0, + float max = 1, + // Offset position on screen + int group = 0, + // Auto-adjust min/max to fit the values + bool autoScale = true + ) + { + color = new Color(r, g, b, 0.9f); + this.min = min; + this.max = max; + this.group = group; + this.autoScale = autoScale; + } +} \ No newline at end of file diff --git a/addons/DebugGUI/Attributes/DebugGUIPrintAttribute.cs b/addons/DebugGUI/Attributes/DebugGUIPrintAttribute.cs new file mode 100644 index 00000000..c20ac6e9 --- /dev/null +++ b/addons/DebugGUI/Attributes/DebugGUIPrintAttribute.cs @@ -0,0 +1,4 @@ +using System; + +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +public class DebugGUIPrintAttribute : Attribute { } \ No newline at end of file diff --git a/addons/DebugGUI/DebugGUI.cs b/addons/DebugGUI/DebugGUI.cs new file mode 100644 index 00000000..b4633284 --- /dev/null +++ b/addons/DebugGUI/DebugGUI.cs @@ -0,0 +1,317 @@ +using Godot; +using System.IO; +using WeavUtils; + +public partial class DebugGUI : Control +{ + // Other scripts may use us right off the bat, so we make sure we initialize first + public DebugGUI() + { + ProcessPhysicsPriority = int.MinValue; + } + + static DebugGUI Instance; + + #region Settings + + public static class Settings + { + const string DEBUGGUI_SETTINGS_DIR = "DebugGUI/Settings/"; + + public static void Init() + { + if (!Engine.IsEditorHint()) return; + + // Inits defaults or load current if present + Load(); + + ProjectSettings.SetSetting($"{DEBUGGUI_SETTINGS_DIR}{nameof(enableGraphs)}", enableGraphs); + ProjectSettings.SetSetting($"{DEBUGGUI_SETTINGS_DIR}{nameof(enableLogs)}", enableLogs); + + ProjectSettings.SetSetting($"{DEBUGGUI_SETTINGS_DIR}{nameof(backgroundColor)}", backgroundColor); + ProjectSettings.SetSetting($"{DEBUGGUI_SETTINGS_DIR}{nameof(scrubberColor)}", scrubberColor); + ProjectSettings.SetSetting($"{DEBUGGUI_SETTINGS_DIR}{nameof(graphWidth)}", graphWidth); + ProjectSettings.SetSetting($"{DEBUGGUI_SETTINGS_DIR}{nameof(graphHeight)}", graphHeight); + ProjectSettings.SetSetting($"{DEBUGGUI_SETTINGS_DIR}{nameof(temporaryLogLifetime)}", temporaryLogLifetime); + + ProjectSettings.SetAsBasic($"{DEBUGGUI_SETTINGS_DIR}{nameof(enableGraphs)}", true); + ProjectSettings.SetAsBasic($"{DEBUGGUI_SETTINGS_DIR}{nameof(enableLogs)}", true); + + ProjectSettings.SetAsBasic($"{DEBUGGUI_SETTINGS_DIR}{nameof(backgroundColor)}", true); + ProjectSettings.SetAsBasic($"{DEBUGGUI_SETTINGS_DIR}{nameof(scrubberColor)}", true); + ProjectSettings.SetAsBasic($"{DEBUGGUI_SETTINGS_DIR}{nameof(graphWidth)}", true); + ProjectSettings.SetAsBasic($"{DEBUGGUI_SETTINGS_DIR}{nameof(graphHeight)}", true); + ProjectSettings.SetAsBasic($"{DEBUGGUI_SETTINGS_DIR}{nameof(temporaryLogLifetime)}", true); + + var err = ProjectSettings.Save(); + if(err != Error.Ok) + { + GD.PrintErr(err); + } + } + + public static void Load() + { + textFont = ThemeDB.FallbackFont; + + enableGraphs = ProjectSettings.GetSetting( + $"{DEBUGGUI_SETTINGS_DIR}{nameof(enableGraphs)}", + true + ).AsBool(); + enableLogs = ProjectSettings.GetSetting( + $"{DEBUGGUI_SETTINGS_DIR}{nameof(enableLogs)}", + true + ).AsBool(); + + backgroundColor = ProjectSettings.GetSetting( + $"{DEBUGGUI_SETTINGS_DIR}{nameof(backgroundColor)}", + new Color(0f, 0f, 0f, 0.7f) + ).AsColor(); + scrubberColor = ProjectSettings.GetSetting( + $"{DEBUGGUI_SETTINGS_DIR}{nameof(scrubberColor)}", + new Color(1f, 1f, 0f, 0.7f) + ).AsColor(); + graphWidth = ProjectSettings.GetSetting( + $"{DEBUGGUI_SETTINGS_DIR}{nameof(graphWidth)}", + 300 + ).AsInt32(); + graphHeight = ProjectSettings.GetSetting( + $"{DEBUGGUI_SETTINGS_DIR}{nameof(graphHeight)}", + 100 + ).AsInt32(); + temporaryLogLifetime = ProjectSettings.GetSetting( + $"{DEBUGGUI_SETTINGS_DIR}{nameof(temporaryLogLifetime)}", + 5 + ).AsDouble(); + } + + public static bool enableGraphs; + public static bool enableLogs; + + public static Color backgroundColor; + public static Color scrubberColor; + public static int graphWidth; + public static int graphHeight; + public static double temporaryLogLifetime; + + public static Font textFont; + } + + #endregion + + #region Graph + + /// + /// Set the properties of a graph. + /// + /// The graph's key + /// The graph's label + /// Value at the bottom of the graph box + /// Value at the top of the graph box + /// The graph's ordinal position on screen + /// The graph's color + public static void SetGraphProperties(object key, string label, float min, float max, int group, Color color, bool autoScale) + { + if (Settings.enableGraphs) + Instance?.graphWindow.SetGraphProperties(key, label, min, max, group, color, autoScale); + } + + /// + /// Set the properties of a graph. + /// + /// The graph's key + /// The graph's label + /// Value at the bottom of the graph box + /// Value at the top of the graph box + /// The graph's ordinal position on screen + /// The graph's color + public static void SetGraphProperties(GodotObject key, string label, float min, float max, int group, Color color, bool autoScale) + { + SetGraphProperties((object)key, label, min, max, group, color, autoScale); + } + + /// + /// Add a data point to a graph. + /// + /// The graph's key + /// Value to be added + public static void Graph(object key, float val) + { + if (Settings.enableGraphs) + Instance?.graphWindow.Graph(key, val); + } + + /// + /// Add a data point to a graph. + /// + /// The graph's key + /// Value to be added + public static void Graph(GodotObject key, float val) + { + Graph((object)key, val); + } + + /// + /// Remove an existing graph. + /// + /// The graph's key + public static void RemoveGraph(object key) + { + if (Settings.enableGraphs) + Instance?.graphWindow.RemoveGraph(key); + } + + /// + /// Remove an existing graph. + /// + /// The graph's key + public static void RemoveGraph(GodotObject key) + { + RemoveGraph((object)key); + } + + /// + /// Resets a graph's data. + /// + /// The graph's key + public static void ClearGraph(object key) + { + if (Settings.enableGraphs) + Instance?.graphWindow.ClearGraph(key); + } + + /// + /// Resets a graph's data. + /// + /// The graph's key + public static void ClearGraph(GodotObject key) + { + ClearGraph((object)key); + } + + /// + /// Export graphs to a json file. See path in log. + /// + public static void ExportGraphs() + { + if (Instance == null || !Settings.enableGraphs) + return; + + string dateTimeStr = Time.GetDatetimeStringFromSystem().Replace(':', '-'); + string filename = $"debuggui_graph_export_{dateTimeStr}.json"; + + using var file = Godot.FileAccess.Open( + "user://" + filename, + Godot.FileAccess.ModeFlags.Write + ); + + if (file == null) + { + GD.Print("DebugGUI graph export failed: " + Godot.FileAccess.GetOpenError()); + } + else + { + file.StoreString(Instance.graphWindow.ToJson()); + GD.Print($"Wrote graph data to {Path.Combine(OS.GetUserDataDir(), filename)}"); + } + } + + #endregion + + #region Log + + /// + /// Create or update an existing message with the same key. + /// + public static void LogPersistent(object key, string message) + { + if (Settings.enableLogs) + Instance?.logWindow.LogPersistent(key, message); + } + + /// + /// Create or update an existing message with the same key. + /// + public static void LogPersistent(GodotObject key, string message) + { + LogPersistent((object)key, message); + } + + /// + /// Remove an existing persistent message. + /// + public static void RemovePersistent(object key) + { + if (Settings.enableLogs) + Instance?.logWindow.RemovePersistent(key); + } + + /// + /// Remove an existing persistent message. + /// + public static void RemovePersistent(GodotObject key) + { + RemovePersistent((object)key); + } + + /// + /// Clears all persistent logs. + /// + public static void ClearPersistent() + { + if (Settings.enableLogs) + Instance?.logWindow.ClearPersistent(); + } + + /// + /// Print a temporary message. + /// + public static void Log(object message) + { + Log(message.ToString()); + } + + /// + /// Print a temporary message. + /// + public static void Log(string message) + { + if (Settings.enableLogs) + Instance?.logWindow.Log(message); + } + + #endregion + + /// + /// Re-scans for DebugGUI attribute holders (i.e. [DebugGUIGraph] and [DebugGUIPrint]) + /// + public static void ForceReinitializeAttributes() + { + if (Instance == null) return; + + Instance.graphWindow.ReinitializeAttributes(); + Instance.logWindow.ReinitializeAttributes(); + } + + GraphWindow graphWindow; + LogWindow logWindow; + + public override void _Ready() + { + Instance = this; + Settings.Load(); + + if (Settings.enableGraphs) + { + CanvasLayer canvasLayer = new(); + canvasLayer.AddChild(graphWindow = new()); + AddChild(canvasLayer); + } + if (Settings.enableGraphs) + { + CanvasLayer canvasLayer = new(); + canvasLayer.AddChild(logWindow = new()); + AddChild(canvasLayer); + } + } +} diff --git a/addons/DebugGUI/DebugGUISettingsInitializer.cs b/addons/DebugGUI/DebugGUISettingsInitializer.cs new file mode 100644 index 00000000..bbbcd885 --- /dev/null +++ b/addons/DebugGUI/DebugGUISettingsInitializer.cs @@ -0,0 +1,20 @@ +#if TOOLS +using Godot; + +[Tool] +public partial class DebugGUISettingsInitializer : EditorPlugin +{ + const string DEBUGGUI_RES_PATH = "res://addons/DebugGUI/DebugGUI.cs"; + + public override void _EnterTree() + { + DebugGUI.Settings.Init(); + AddAutoloadSingleton(nameof(DebugGUI), DEBUGGUI_RES_PATH); + } + + public override void _ExitTree() + { + RemoveAutoloadSingleton(nameof(DebugGUI)); + } +} +#endif diff --git a/addons/DebugGUI/Examples/DebugGUIExamples.cs b/addons/DebugGUI/Examples/DebugGUIExamples.cs new file mode 100644 index 00000000..89fc7d25 --- /dev/null +++ b/addons/DebugGUI/Examples/DebugGUIExamples.cs @@ -0,0 +1,148 @@ +using Godot; +using System.Collections.Generic; +using System.Linq; + +public partial class DebugGUIExamples : Node +{ + /* * * * + * + * [DebugGUIGraph] + * Renders the variable in a graph on-screen. Attribute based graphs will updates every _Process. + * Lets you optionally define: + * max, min - The range of displayed values + * r, g, b - The RGB color of the graph (0~1) + * group - Graphs can be grouped into the same window and overlaid + * autoScale - If true the graph will readjust min/max to fit the data + * + * [DebugGUIPrint] + * Draws the current variable continuously on-screen as + * $"{GameObject name} {variable name}: {value}" + * + * For more control, these features can be accessed manually. + * DebugGUI.SetGraphProperties(key, ...) - Set the properties of the graph with the provided key + * DebugGUI.Graph(key, value) - Push a value to the graph + * DebugGUI.LogPersistent(key, value) - Print a persistent log entry on screen + * DebugGUI.Log(value) - Print a temporary log entry on screen + * + * See DebugGUI.cs for more info + * + * * * */ + + // Disable Field Unused warning +#pragma warning disable 0414 + + // Works with regular fields + [DebugGUIGraph(min: -1, max: 1, r: 0, g: 1, b: 0, autoScale: true)] + float SinField; + + // As well as properties + [DebugGUIGraph(min: -1, max: 1, r: 0, g: 1, b: 1, autoScale: true)] + float CosProperty { get { return Mathf.Cos(time * 6); } } + + // Also works for expression-bodied properties + [DebugGUIGraph(min: -1, max: 1, r: 1, g: 0.3f, b: 1)] + float SinProperty => Mathf.Sin((time + Mathf.Pi / 2) * 6); + + // User inputs, print and graph in one! + [DebugGUIPrint, DebugGUIGraph(group: 1, r: 1, g: 0.3f, b: 0.3f)] + float mouseX; + [DebugGUIPrint, DebugGUIGraph(group: 1, r: 0, g: 1, b: 0)] + float mouseY; + + Queue deltaTimeBuffer = new(); + double smoothDeltaTime => deltaTimeBuffer.Sum() / deltaTimeBuffer.Count; + float time; + float physicsTime; + bool wasMouseDown; + + public override void _Ready() + { + + // Init smooth DT + for (int i = 0; i < 10; i++) + { + deltaTimeBuffer.Enqueue(0); + } + + // Log (as opposed to LogPersistent) will disappear automatically after some time. + DebugGUI.Log("Hello! I will disappear after some time!"); + + // Set up graph properties using our graph keys + DebugGUI.SetGraphProperties("smoothFrameRate", "SmoothFPS", 0, 200, 2, new Color(0, 1, 1), false); + DebugGUI.SetGraphProperties("frameRate", "FPS", 0, 200, 2, new Color(1, 0.5f, 1), false); + DebugGUI.SetGraphProperties("fixedFrameRateSin", "FixedSin", -1, 1, 3, new Color(1, 1, 0), true); + } + + public override void _Process(double delta) + { + time += (float)delta; + + // Update smooth delta time queue + deltaTimeBuffer.Dequeue(); + deltaTimeBuffer.Enqueue(delta); + + // Update the fields our attributes are graphing + SinField = Mathf.Sin(time * 6); + + // Update graphed mouse XY values + var mousePos = GetViewport().GetMousePosition(); + var viewportRect = GetViewport().GetVisibleRect(); + mouseX = Mathf.Clamp(mousePos.X, 0, viewportRect.Size.X); + mouseY = Mathf.Clamp(mousePos.Y, 0, viewportRect.Size.Y); + + // Manual persistent logging + DebugGUI.LogPersistent("smoothFrameRate", "SmoothFPS: " + (1 / smoothDeltaTime).ToString("F3")); + DebugGUI.LogPersistent("frameRate", "FPS: " + (1 / delta).ToString("F3")); + + // Manual logging of mouse clicks + if (Input.IsMouseButtonPressed(MouseButton.Left)) + { + if (!wasMouseDown) + { + wasMouseDown = true; + DebugGUI.Log(string.Format( + "Mouse down ({0}, {1})", + mouseX.ToString("F3"), + mouseY.ToString("F3") + )); + } + } + else + { + wasMouseDown = false; + } + + if (smoothDeltaTime != 0) + { + DebugGUI.Graph("smoothFrameRate", 1 / (float)smoothDeltaTime); + } + if (delta != 0) + { + DebugGUI.Graph("frameRate", 1 / (float)delta); + } + + if (Input.IsKeyPressed(Key.Space)) + { + QueueFree(); + } + } + + public override void _PhysicsProcess(double delta) + { + physicsTime += (float)delta; + + // Manual graphing + DebugGUI.Graph("fixedFrameRateSin", Mathf.Sin(physicsTime * 6)); + } + + public override void _ExitTree() + { + // Clean up our logs and graphs when this object leaves tree + DebugGUI.RemoveGraph("frameRate"); + DebugGUI.RemoveGraph("fixedFrameRateSin"); + DebugGUI.RemoveGraph("smoothFrameRate"); + + DebugGUI.RemovePersistent("frameRate"); + DebugGUI.RemovePersistent("smoothFrameRate"); + } +} \ No newline at end of file diff --git a/addons/DebugGUI/Examples/DebugGUIExamples.tscn b/addons/DebugGUI/Examples/DebugGUIExamples.tscn new file mode 100644 index 00000000..807a8c39 --- /dev/null +++ b/addons/DebugGUI/Examples/DebugGUIExamples.tscn @@ -0,0 +1,16 @@ +[gd_scene load_steps=3 format=3 uid="uid://cwxrep5n7yml0"] + +[ext_resource type="Script" path="res://addons/DebugGUI/Examples/DebugGUIExamples.cs" id="1_herxx"] +[ext_resource type="Script" path="res://addons/DebugGUI/Examples/debugGUI_examples.gd" id="2_acgyp"] + +[node name="C# Example" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_herxx") + +[node name="gdscript example" type="Node" parent="."] +script = ExtResource("2_acgyp") diff --git a/addons/DebugGUI/Examples/debugGUI_examples.gd b/addons/DebugGUI/Examples/debugGUI_examples.gd new file mode 100644 index 00000000..7b6dfb78 --- /dev/null +++ b/addons/DebugGUI/Examples/debugGUI_examples.gd @@ -0,0 +1,9 @@ +extends Node + +func _ready(): + DebugGUI.SetGraphProperties(self, "from gdscript", 0.0, 10.0, 1, Color.WHITE, true) + DebugGUI.Log(self) + DebugGUI.Log("This can be done from gdscript too!") + +func _process(_delta): + DebugGUI.Graph(self, sin(Time.get_ticks_msec() / 100.0)) diff --git a/addons/DebugGUI/Windows/DebugGUIWindow.cs b/addons/DebugGUI/Windows/DebugGUIWindow.cs new file mode 100644 index 00000000..f6493f8d --- /dev/null +++ b/addons/DebugGUI/Windows/DebugGUIWindow.cs @@ -0,0 +1,61 @@ +using Godot; +using System; + +namespace WeavUtils +{ + // Draggable window clamped to the corners + public abstract partial class DebugGUIWindow : Control + { + protected const int outOfScreenClampPadding = 30; + + static bool dragInProgress; + bool dragged; + + new public virtual Rect2 GetRect() + { + return base.GetRect(); + } + + public override void _Input(InputEvent @event) + { + if (@event is InputEventMouseButton mb) + { + if (!dragInProgress && mb.Pressed && mb.ButtonIndex == MouseButton.Middle) + { + if (GetRect().HasPoint(mb.Position)) + { + dragged = true; + dragInProgress = true; + } + } + if (mb.IsReleased() && mb.ButtonIndex == MouseButton.Middle) + { + if (dragged) dragInProgress = false; + dragged = false; + } + } + + if (@event is InputEventMouseMotion motion) + { + if (dragged) + { + Move(motion.Relative); + } + } + } + + protected void Move(Vector2 delta = default) + { + Position += delta; + + var viewportRect = GetViewportRect(); + + // Limit graph window offset so we can't get lost off screen + Position = Position.Clamp( + -GetRect().Size + Vector2.One * outOfScreenClampPadding, + viewportRect.Size - Vector2.One * outOfScreenClampPadding + ); + } + + } +} \ No newline at end of file diff --git a/addons/DebugGUI/Windows/GraphWindow.cs b/addons/DebugGUI/Windows/GraphWindow.cs new file mode 100644 index 00000000..b4e99962 --- /dev/null +++ b/addons/DebugGUI/Windows/GraphWindow.cs @@ -0,0 +1,693 @@ +using Godot; +using System; +using System.Collections.Generic; +using System.Reflection; +using static DebugGUI.Settings; + +namespace WeavUtils +{ + public partial class GraphWindow : DebugGUIWindow + { + const int graphLabelFontSize = 12; + const int graphLabelPadding = 5; + const int graphBlockPadding = 3; + const int scrubberBackgroundWidth = 55; + const int windowOutOfScreenPadding = 30; + + List graphs = new(); + HashSet attributeContainers = new(); + Dictionary typeInstanceCounts = new(); + Dictionary graphDictionary = new(); + Dictionary> attributeKeys = new(); + Dictionary> debugGUIGraphFields = new(); + Dictionary> debugGUIGraphProperties = new(); + SortedDictionary> graphGroups = new(); + + bool freezeGraphs; + float graphLabelBoxWidth; + + public override void _Ready() + { + Name = nameof(GraphWindow); + + // Register attributes of all nodes present at start + // See also: ReinitializeAttributes() + RegisterAttributes(((SceneTree)Engine.GetMainLoop()).Root); + + // Default to top right + Position = new Vector2(GetViewportRect().Size.X - GetRect().Size.X, 0); + } + + public override void _Process(double delta) + { + if (!Input.IsMouseButtonPressed(MouseButton.Left)) + { + freezeGraphs = false; + } + + if (!freezeGraphs) + { + CallDeferred(nameof(PollGraphAttributes)); + } + + QueueRedraw(); + + // Clean up any nodes queued to free + CallDeferred(nameof(CleanUpDeletedAttributes)); + } + + public override void _Draw() + { + int groupNum = 0; + foreach (var group in graphGroups.Values) + { + DrawGraphGroup(group, groupNum); + groupNum++; + } + } + + public void Graph(object key, float val) + { + if (!graphDictionary.ContainsKey(key)) + { + CreateGraph(key); + } + + if (freezeGraphs) return; + + graphDictionary[key].Push(val); + // Todo: optimize away? + RecalculateGraphLabelWidth(); + } + + public void CreateGraph(object key) + { + AddGraph(key, new GraphContainer(graphWidth)); + RecalculateGraphLabelWidth(); + } + + public void ClearGraph(object key) + { + if (graphDictionary.ContainsKey(key)) + graphDictionary[key].Clear(); + } + + public void RemoveGraph(object key) + { + if (graphDictionary.ContainsKey(key)) + { + var graph = graphDictionary[key]; + graphs.Remove(graph); + graphDictionary.Remove(key); + graphGroups[graph.group].Remove(graph); + if (graphGroups[graph.group].Count == 0) + { + graphGroups.Remove(graph.group); + } + RecalculateGraphLabelWidth(); + } + } + + public void SetGraphProperties(object key, string label, float min, float max, int group, Color color, bool autoScale) + { + if (graphDictionary.ContainsKey(key)) + { + RemoveGraph(key); + } + + var graph = new GraphContainer(graphWidth, group); + AddGraph(key, graph); + + graph.name = label; + graph.SetMinMax(min, max); + graph.color = color; + graph.autoScale = autoScale; + } + + public void ReinitializeAttributes() + { + // Clean up graphs + List toRemove = new List(); + foreach (var key in graphDictionary.Keys) + { + if (key is GraphAttributeKey) + toRemove.Add(key); + } + foreach (var key in toRemove) + { + RemoveGraph(key); + } + + attributeContainers = new(); + debugGUIGraphFields = new(); + debugGUIGraphProperties = new(); + typeInstanceCounts = new(); + attributeKeys = new(); + + RegisterAttributes(((SceneTree)Engine.GetMainLoop()).Root); + } + + public string ToJson() + { + var data = new Godot.Collections.Array(); + + foreach (var node in graphs) + { + data.Add(node.ToDataVariant()); + } + + return data.ToString(); + } + + public override Rect2 GetRect() + { + RefreshRect(); + return base.GetRect(); + } + + private void AddGraph(object key, GraphContainer graph) + { + graph.OnLabelSizeChange += RefreshRect; + + graphDictionary.Add(key, graph); + graphs.Add(graph); + + if (!graphGroups.ContainsKey(graph.group)) + { + graphGroups.Add(graph.group, new List()); + } + + graphGroups[graph.group].Add(graph); + + RecalculateGraphLabelWidth(); + } + + private void PollGraphAttributes() + { + foreach (var node in attributeContainers) + { + if (node != null && attributeKeys.ContainsKey(node)) + { + foreach (var key in attributeKeys[node]) + { + if (key.memberInfo is FieldInfo fieldInfo) + { + float? val = fieldInfo.GetValue(node) as float?; + if (val != null) + graphDictionary[key].Push(val.Value); + } + else if (key.memberInfo is PropertyInfo propertyInfo) + { + float? val = propertyInfo.GetValue(node, null) as float?; + if (val != null) + graphDictionary[key].Push(val.Value); + } + } + } + } + } + + GraphContainer lastPressedGraphLabel; + private void DrawGraphGroup(List group, int groupNum) + { + var mousePos = GetLocalMousePosition(); + + Vector2 graphBlockSize = new Vector2(graphWidth + graphBlockPadding, graphHeight + graphBlockPadding); + + var groupOrigin = new Vector2(0, graphBlockSize.Y * groupNum); + var groupGraphRect = new Rect2( + groupOrigin.X + graphLabelBoxWidth + graphBlockPadding, + groupOrigin.Y, + graphWidth, + graphHeight + ); + + // Label background + DrawRect(new Rect2( + groupOrigin.X, + groupOrigin.Y, + graphLabelBoxWidth, + graphHeight), + backgroundColor); + + // Graph background + DrawRect(new Rect2( + groupOrigin.X + graphBlockPadding + graphLabelBoxWidth, + groupOrigin.Y, + graphBlockSize.X, + graphHeight), + backgroundColor); + + // Magic padding offsets + Vector2 textOrigin = groupOrigin + new Vector2(0, 14); + Vector2 minMaxOrigin = groupOrigin + new Vector2(graphLabelBoxWidth - 10, 16); + foreach (var graph in group) + { + var textSize = textFont.GetStringSize(graph.name, fontSize: graphLabelFontSize); + textOrigin.Y += textSize.Y; + var maxWidthOfMinMaxStrings = Mathf.Max( + textFont.GetStringSize(graph.minString, fontSize: graphLabelFontSize).X, + textFont.GetStringSize(graph.maxString, fontSize: graphLabelFontSize).X + ); + minMaxOrigin += Vector2.Left * (maxWidthOfMinMaxStrings + graphLabelPadding); + + // Label button logic + var labelRect = new Rect2(textOrigin - textSize + new Vector2(graphLabelBoxWidth - (graphLabelPadding * 2), graphLabelPadding), textSize); + // Enable disable + var isHovered = labelRect.HasPoint(mousePos); + var isPressed = isHovered && Input.IsMouseButtonPressed(MouseButton.Left); + + // Button click + if (lastPressedGraphLabel == graph && !isPressed && isHovered) + { + graph.visible = !graph.visible; + } + + if (isPressed) + { + lastPressedGraphLabel = graph; + } + else if (lastPressedGraphLabel == graph) + { + lastPressedGraphLabel = null; + } + + var graphColor = graph.GetModifiedColor(isHovered); + + // Name + DrawString( + textFont, + textOrigin - new Vector2(textSize.X + 10 - graphLabelBoxWidth, 0), + graph.name, + fontSize: graphLabelFontSize, + modulate: graphColor, + alignment: HorizontalAlignment.Right + ); + + // Max + DrawString( + textFont, + minMaxOrigin, + graph.maxString, + modulate: graphColor, + fontSize: graphLabelFontSize, + alignment: HorizontalAlignment.Right + ); + + // Min + DrawString( + textFont, + minMaxOrigin + new Vector2(0, graphHeight - 20), + graph.minString, + modulate: graphColor, + fontSize: graphLabelFontSize, + alignment: HorizontalAlignment.Right + ); + + // Graph + if (graph.visible) + { + graph.Draw(groupGraphRect, this); + } + } + + // Scrubber + if (groupGraphRect.HasPoint(mousePos)) + { + if (Input.IsMouseButtonPressed(MouseButton.Left)) + { + freezeGraphs = true; + } + + // Background + Vector2 scrubberOrigin = new Vector2(mousePos.X, groupOrigin.Y); + if (mousePos.X > groupGraphRect.End.X - scrubberBackgroundWidth) + { + scrubberOrigin.X -= scrubberBackgroundWidth; + } + + var rect = new Rect2( + scrubberOrigin, + scrubberBackgroundWidth, + graphHeight + ); + DrawRect(rect, backgroundColor); + + DrawLine( + new Vector2( + mousePos.X, + groupOrigin.Y + ), new Vector2( + mousePos.X, + groupOrigin.Y + graphHeight + ), + scrubberColor + ); + + // Scrubber labels + Vector2 textPos = scrubberOrigin + new Vector2(graphLabelPadding, graphLabelPadding * 3); + var groupMousePosX = (mousePos.X - groupOrigin.X); + int sampleIndex = (int)(groupGraphRect.Size.X - groupMousePosX + graphLabelBoxWidth + graphBlockPadding); + foreach (GraphContainer graph in group) + { + var text = graph.GetValue(sampleIndex).ToString("F3"); + DrawString( + textFont, + textPos, + text, + modulate: graph.color, + fontSize: graphLabelFontSize + ); + textPos.Y += textFont.GetHeight(graphLabelFontSize); + } + } + } + + private Rect2 GetGraphWindowRect() + { + return new Rect2( + new Vector2(-graphLabelBoxWidth, 0) + Position, + graphWidth + graphLabelBoxWidth + graphBlockPadding, + (graphHeight + graphBlockPadding) * graphGroups.Count + ); + } + + private void RegisterAttributes(Node node) + { + foreach (Node child in node.GetChildren()) + { + GD.Print(child); + RegisterAttributes(child); + } + + Type nodeType = node.GetType(); + + HashSet uniqueAttributeContainers = new(); + // Fields + { + // Retreive the fields from the mono instance + FieldInfo[] objectFields = nodeType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + // search all fields/properties for the [DebugGUIVar] attribute + for (int i = 0; i < objectFields.Length; i++) + { + DebugGUIGraphAttribute graphAttribute = Attribute.GetCustomAttribute(objectFields[i], typeof(DebugGUIGraphAttribute)) as DebugGUIGraphAttribute; + + if (graphAttribute != null) + { + // Can't cast to float so we don't bother registering it + if (objectFields[i].GetValue(node) as float? == null) + { + GD.PrintErr(string.Format("Cannot cast {0}.{1} to float. This member will be ignored.", nodeType.Name, objectFields[i].Name)); + continue; + } + + uniqueAttributeContainers.Add(node); + if (!debugGUIGraphFields.ContainsKey(nodeType)) + debugGUIGraphFields.Add(nodeType, new HashSet()); + if (!debugGUIGraphProperties.ContainsKey(nodeType)) + debugGUIGraphProperties.Add(nodeType, new HashSet()); + + debugGUIGraphFields[nodeType].Add(objectFields[i]); + GraphContainer graph = + new GraphContainer(graphWidth, graphAttribute.group) + { + name = objectFields[i].Name, + max = graphAttribute.max, + min = graphAttribute.min, + autoScale = graphAttribute.autoScale + }; + graph.OnLabelSizeChange += RefreshRect; + if (!graphAttribute.color.Equals(default(Color))) + graph.color = graphAttribute.color; + + var key = new GraphAttributeKey(objectFields[i]); + if (!attributeKeys.ContainsKey(node)) + attributeKeys.Add(node, new List()); + attributeKeys[node].Add(key); + + AddGraph(key, graph); + } + } + } + + // Properties + { + PropertyInfo[] objectProperties = nodeType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + for (int i = 0; i < objectProperties.Length; i++) + { + if (Attribute.GetCustomAttribute(objectProperties[i], typeof(DebugGUIGraphAttribute)) is DebugGUIGraphAttribute graphAttribute) + { + // Can't cast to float so we don't bother registering it + if (objectProperties[i].GetValue(node, null) as float? == null) + { + GD.PrintErr("Cannot cast " + objectProperties[i].Name + " to float. This member will be ignored."); + continue; + } + + uniqueAttributeContainers.Add(node); + + if (!debugGUIGraphFields.ContainsKey(nodeType)) + debugGUIGraphFields.Add(nodeType, new HashSet()); + if (!debugGUIGraphProperties.ContainsKey(nodeType)) + debugGUIGraphProperties.Add(nodeType, new HashSet()); + + debugGUIGraphProperties[nodeType].Add(objectProperties[i]); + + GraphContainer graph = + new GraphContainer(graphWidth, graphAttribute.group) + { + name = objectProperties[i].Name, + max = graphAttribute.max, + min = graphAttribute.min, + autoScale = graphAttribute.autoScale + }; + graph.OnLabelSizeChange += RefreshRect; + if (!graphAttribute.color.Equals(default(Color))) + graph.color = graphAttribute.color; + + var key = new GraphAttributeKey(objectProperties[i]); + if (!attributeKeys.ContainsKey(node)) + attributeKeys.Add(node, new List()); + attributeKeys[node].Add(key); + + AddGraph(key, graph); + } + } + } + + foreach (var attributeContainer in uniqueAttributeContainers) + { + attributeContainers.Add(attributeContainer); + Type type = attributeContainer.GetType(); + if (!typeInstanceCounts.ContainsKey(type)) + typeInstanceCounts.Add(type, 0); + typeInstanceCounts[type]++; + } + } + + private void CleanUpDeletedAttributes() + { + // Clear out associated keys + foreach (var node in attributeContainers) + { + if (node.IsQueuedForDeletion()) + { + var keys = attributeKeys[node]; + foreach (var key in keys) + { + RemoveGraph(key); + } + attributeKeys.Remove(node); + + Type type = node.GetType(); + typeInstanceCounts[type]--; + if (typeInstanceCounts[type] == 0) + { + if (debugGUIGraphFields.ContainsKey(type)) + debugGUIGraphFields.Remove(type); + if (debugGUIGraphProperties.ContainsKey(type)) + debugGUIGraphProperties.Remove(type); + } + } + } + + // Finally clear out removed nodes + attributeContainers.RemoveWhere(node => node.IsQueuedForDeletion()); + } + + void RefreshRect() + { + var lastWidth = Size.X; + RecalculateGraphLabelWidth(); + Size = new Vector2( + graphWidth + graphLabelBoxWidth + graphBlockPadding, + (graphHeight + graphBlockPadding) * graphGroups.Count); + // Grow to the left instead of right + Position += new Vector2(lastWidth - Size.X, 0); + } + + void RecalculateGraphLabelWidth() + { + float width = 0; + foreach (var group in graphGroups.Values) + { + float minMaxWidth = graphLabelPadding; + foreach (var graph in group) + { + // Names + width = Mathf.Max(textFont.GetStringSize(graph.name, fontSize: graphLabelFontSize).X, width); + + // Minmax labels per group + var maxWidthOfMinMaxStrings = Mathf.Max( + textFont.GetStringSize(graph.minString, fontSize: graphLabelFontSize).X, + textFont.GetStringSize(graph.maxString, fontSize: graphLabelFontSize).X + ); + minMaxWidth += maxWidthOfMinMaxStrings + graphLabelPadding; + } + width = Mathf.Max(minMaxWidth, width); + } + graphLabelBoxWidth = width + graphLabelPadding * 2; + } + + private class GraphContainer + { + public Action OnLabelSizeChange; + + public string name; + + // Value at the top of the graph + public float max = 1; + // Value at the bottom of the graph + public float min = 0; + public bool autoScale; + public Color color; + // Graph order on screen + public readonly int group; + + private int currentIndex; + private readonly float[] values; + private readonly Vector2[] graphPoints; + + public string minString = null; + public string maxString = null; + public bool visible = true; + + public Color GetModifiedColor(bool highlighted) + { + if (!highlighted && visible) return color; + color.ToHsv(out float h, out float s, out float v); + + if (!visible) v *= 0.3f; + if (highlighted) v *= (v > 0.9f ? 0.7f : 1.2f); + return Color.FromHsv(h, s, v); + } + + public void SetMinMax(float min, float max) + { + OnLabelSizeChange?.Invoke(); + this.min = min; + this.max = max; + + minString = min.ToString("F2"); + maxString = max.ToString("F2"); + } + + public GraphContainer(int width, int group = 0) + { + this.group = group; + values = new float[width]; + graphPoints = new Vector2[width]; + SetMinMax(min, max); + } + + // Add a data point to the beginning of the graph + public void Push(float val) + { + if (autoScale && (val > max || val < min)) + { + SetMinMax(Mathf.Min(val, min), Mathf.Max(val, max)); + } + else + { + // Prevent drawing outside frame + val = Mathf.Clamp(val, min, max); + } + + values[currentIndex] = val; + currentIndex = (currentIndex + 1) % values.Length; + } + + public void Clear() + { + for (int i = 0; i < values.Length; i++) + { + values[i] = 0; + } + } + + public void Draw(Rect2 rect, CanvasItem canvasItem) + { + int num = values.Length; + for (int i = 0; i < num; i++) + { + float value = values[Mod(currentIndex - i - 1, values.Length)]; + // Note flipped inverse lerp min max to account for y = down in godot + graphPoints[i] = new Vector2( + rect.Position.X + (rect.Size.X * ((float)i / num)), + rect.Position.Y + (Mathf.InverseLerp(max, min, value) * graphHeight) + ); + } + + canvasItem.DrawPolyline(graphPoints, color); + } + + public float GetValue(int index) + { + return values[Mod(currentIndex + index, values.Length)]; + } + + class DataExport + { + public string name; + public float[] values; + + public DataExport(string name, float[] values) + { + this.name = name; + this.values = values; + } + } + + public Variant ToDataVariant() + { + var vals = new float[values.Length]; + for (int i = 0; i < vals.Length; i++) + { + vals[i] = values[Mod(currentIndex + i, values.Length)]; + } + + var dict = new Godot.Collections.Dictionary(); + dict.Add("name", name); + dict.Add("values", vals); + + return dict; + } + + private static int Mod(int n, int m) + { + return ((n % m) + m) % m; + } + } + + public class GraphAttributeKey + { + public MemberInfo memberInfo; + public GraphAttributeKey(MemberInfo memberInfo) + { + this.memberInfo = memberInfo; + } + } + } +} \ No newline at end of file diff --git a/addons/DebugGUI/Windows/LogWindow.cs b/addons/DebugGUI/Windows/LogWindow.cs new file mode 100644 index 00000000..f03a07a3 --- /dev/null +++ b/addons/DebugGUI/Windows/LogWindow.cs @@ -0,0 +1,308 @@ +using Godot; +using System; +using System.Collections.Generic; +using System.Text; +using System.Reflection; +using static DebugGUI.Settings; + +namespace WeavUtils +{ + public partial class LogWindow : DebugGUIWindow + { + List transientLogs = new(); + HashSet attributeContainers = new(); + Dictionary typeCache = new(); + Dictionary typeInstanceCounts = new(); + Dictionary persistentLogs = new(); + Dictionary> attributeKeys = new(); + Dictionary> debugGUIPrintFields = new(); + Dictionary> debugGUIPrintProperties = new(); + + StringBuilder persistentLogStringBuilder = new(); + + double time; + + public override void _Ready() + { + Name = nameof(LogWindow); + + // Register attributes of all nodes present at start + // See also: ReinitializeAttributes() + RegisterAttributes(((SceneTree)Engine.GetMainLoop()).Root); + } + + public override void _Process(double delta) + { + time += delta; + + // Clean up expired logs + int expiredCt = 0; + for (int i = 0; i < transientLogs.Count; i++) + { + if (transientLogs[i].expiryTime <= time) + { + expiredCt++; + } + } + transientLogs.RemoveRange(0, expiredCt); + + if (debugGUIPrintFields.Count + debugGUIPrintProperties.Count + persistentLogs.Count + transientLogs.Count > 0) + { + QueueRedraw(); + } + + CallDeferred(nameof(CleanUpDeletedAttributes)); + } + + public override void _Draw() + { + persistentLogStringBuilder.Clear(); + + var viewportRect = GetViewportRect(); + var lineHeight = textFont.GetHeight(); + + foreach (var node in attributeContainers) + { + Type type = typeCache[node]; + if (debugGUIPrintFields.ContainsKey(type)) + { + foreach (var field in debugGUIPrintFields[type]) + { + persistentLogStringBuilder.AppendLine($"{node.Name} {field.Name}: {field.GetValue(node)}"); + } + } + if (debugGUIPrintProperties.ContainsKey(type)) + { + foreach (var property in debugGUIPrintProperties[type]) + { + persistentLogStringBuilder.AppendLine($"{node.Name} {property.Name}: {property.GetValue(node, null)}"); + } + } + } + + foreach (var log in persistentLogs.Values) + { + persistentLogStringBuilder.AppendLine(log); + } + + if (persistentLogStringBuilder.Length > 0 && transientLogs.Count != 0) + { + persistentLogStringBuilder.AppendLine(); + } + + var persistentLogStr = persistentLogStringBuilder.ToString(); + var textSize = textFont.GetMultilineStringSize(persistentLogStr); + Size = textSize + Vector2.One * 10; + + float transientLogY = textSize.Y; + + foreach (var log in transientLogs) + { + var size = textFont.GetStringSize(log.text); + textSize = new Vector2( + Mathf.Max(size.X, textSize.X), + textSize.Y + size.Y + ); + } + + + var backgroundRect = new Rect2(Vector2.Zero, textSize.X + 10, textSize.Y + 10); + DrawRect(backgroundRect, backgroundColor); + // Draw a little bit extra for the draggable area + DrawRect(new Rect2(0, 0, GetRect().Size), new Color(1, 1, 1, 0.05f)); + + // Draw persistent logs + DrawMultilineString(textFont, new Vector2(0, textFont.GetHeight()), persistentLogStr); + + // Draw separator + if (persistentLogStringBuilder.Length > 0 && transientLogs.Count != 0) + { + DrawDashedLine( + new Vector2(0, transientLogY), + new Vector2(textSize.X, transientLogY), + Colors.White, + 2 + ); + } + + // Draw transient logs + for (int i = transientLogs.Count - 1; i >= 0; i--) + { + transientLogY += lineHeight; + // Clear up transient logs going off screen + if (transientLogY > viewportRect.Size.Y) + { + transientLogs.RemoveRange(0, i + 1); + break; + } + + var log = transientLogs[i]; + DrawString(textFont, new Vector2(0, transientLogY), log.text); + } + } + + public void Log(string str) + { + transientLogs.Add(new TransientLog(str, time + temporaryLogLifetime)); + } + + public void LogPersistent(object key, string message) + { + if (persistentLogs.ContainsKey(key)) + persistentLogs[key] = message; + else + persistentLogs.Add(key, message); + } + + public void RemovePersistent(object key) + { + if (persistentLogs.ContainsKey(key)) + { + persistentLogs.Remove(key); + } + } + + public void ClearPersistent() + { + persistentLogs.Clear(); + } + + public void ReinitializeAttributes() + { + // Clean up graphs + List toRemove = new List(); + foreach (var key in persistentLogs.Keys) + { + if (key is PersistentLogAttributeKey) + toRemove.Add(key); + } + foreach (var key in toRemove) + { + persistentLogs.Remove(key); + } + + attributeContainers = new(); + debugGUIPrintFields = new(); + debugGUIPrintProperties = new(); + typeInstanceCounts = new(); + attributeKeys = new(); + } + + private void RegisterAttributes(Node node) + { + foreach (Node child in node.GetChildren()) + { + GD.Print(child); + RegisterAttributes(child); + } + + Type nodeType = node.GetType(); + + + HashSet uniqueAttributeContainers = new(); + + // Fields + { + // Retreive the fields from the mono instance + FieldInfo[] objectFields = nodeType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + // search all fields/properties for the [DebugGUIVar] attribute + for (int i = 0; i < objectFields.Length; i++) + { + DebugGUIPrintAttribute printAttribute = Attribute.GetCustomAttribute(objectFields[i], typeof(DebugGUIPrintAttribute)) as DebugGUIPrintAttribute; + + if (printAttribute != null) + { + uniqueAttributeContainers.Add(node); + typeCache[node] = node.GetType(); + if (!debugGUIPrintFields.ContainsKey(nodeType)) + { + debugGUIPrintFields.Add(nodeType, new HashSet()); + } + + GD.Print("Found field " + objectFields[i].Name + " on " + node); + debugGUIPrintFields[nodeType].Add(objectFields[i]); + } + } + } + + // Properties + { + PropertyInfo[] objectProperties = nodeType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + for (int i = 0; i < objectProperties.Length; i++) + { + if (Attribute.GetCustomAttribute(objectProperties[i], typeof(DebugGUIPrintAttribute)) is DebugGUIPrintAttribute) + { + uniqueAttributeContainers.Add(node); + typeCache[node] = node.GetType(); + + if (!debugGUIPrintProperties.ContainsKey(nodeType)) + { + debugGUIPrintProperties.Add(nodeType, new HashSet()); + } + debugGUIPrintProperties[nodeType].Add(objectProperties[i]); + } + } + } + + foreach (var attributeContainer in uniqueAttributeContainers) + { + attributeContainers.Add(attributeContainer); + Type type = attributeContainer.GetType(); + if (!typeInstanceCounts.ContainsKey(type)) + typeInstanceCounts.Add(type, 0); + typeInstanceCounts[type]++; + } + } + + private void CleanUpDeletedAttributes() + { + // Clear out associated keys + foreach (var node in attributeContainers) + { + if (node.IsQueuedForDeletion()) + { + attributeKeys.Remove(node); + typeCache.Remove(node); + + Type type = node.GetType(); + typeInstanceCounts[type]--; + if (typeInstanceCounts[type] == 0) + { + if (debugGUIPrintFields.ContainsKey(type)) + debugGUIPrintFields.Remove(type); + if (debugGUIPrintProperties.ContainsKey(type)) + debugGUIPrintProperties.Remove(type); + } + } + } + + // Finally clear out removed nodes + attributeContainers.RemoveWhere(node => node.IsQueuedForDeletion()); + } + + private struct TransientLog + { + public string text; + public double expiryTime; + + public TransientLog(string text, double expiryTime) + { + this.text = text; + this.expiryTime = expiryTime; + } + } + + // Wrapper to differentiate attributes from + // manually created logs + public class PersistentLogAttributeKey + { + public MemberInfo memberInfo; + public PersistentLogAttributeKey(MemberInfo memberInfo) + { + this.memberInfo = memberInfo; + } + } + } +} diff --git a/addons/DebugGUI/plugin.cfg b/addons/DebugGUI/plugin.cfg new file mode 100644 index 00000000..5e860a25 --- /dev/null +++ b/addons/DebugGUI/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="DebugGUI" +description="" +author="WeaverDev" +version="1.0" +script="DebugGUISettingsInitializer.cs" diff --git a/project.godot b/project.godot index 14737ad1..817e1f76 100644 --- a/project.godot +++ b/project.godot @@ -8,21 +8,36 @@ config_version=5 +[DebugGUI] + +Settings/enableGraphs=true +Settings/enableLogs=true +Settings/backgroundColor=Color(0, 0, 0, 0.7) +Settings/scrubberColor=Color(1, 1, 0, 0.7) +Settings/graphWidth=300 +Settings/graphHeight=100 +Settings/temporaryLogLifetime=5.0 + [application] config/name="Cirno" -run/main_scene="res://Scenes/game.tscn" +run/main_scene="res://Scenes/test.tscn" config/features=PackedStringArray("4.3", "C#", "GL Compatibility") config/icon="res://icon.svg" [autoload] -DebugStats="*res://Scenes/debug_stats.tscn" +DebugStats="res://Scenes/debug_stats.tscn" +DebugGUI="*res://addons/DebugGUI/DebugGUI.cs" [display] -window/size/viewport_width=1920 -window/size/viewport_height=1080 +window/size/viewport_width=320 +window/size/viewport_height=160 +window/size/window_width_override=1920 +window/size/window_height_override=1080 +window/stretch/mode="canvas_items" +window/stretch/scale_mode="integer" [dotnet] @@ -30,7 +45,7 @@ project/assembly_name="Cirno" [editor_plugins] -enabled=PackedStringArray("res://addons/smoothing/plugin.cfg") +enabled=PackedStringArray("res://addons/DebugGUI/plugin.cfg", "res://addons/smoothing/plugin.cfg") [input]