diff --git a/Game/Root.cs b/Game/Root.cs
new file mode 100644
index 0000000..18127b4
--- /dev/null
+++ b/Game/Root.cs
@@ -0,0 +1,15 @@
+using Godot;
+using GodotHostTest.GodotDI;
+
+namespace GodotHostTest.Game;
+
+public partial class Root : Node2D
+{
+ [Inject] private readonly ISceneInstantiator _sceneInstantiator = null!;
+ [Export] private PackedScene _levelScene = null!;
+
+ public override void _Ready()
+ {
+ AddChild(_sceneInstantiator.Instantiate(_levelScene));
+ }
+}
\ No newline at end of file
diff --git a/Game/Root.cs.uid b/Game/Root.cs.uid
new file mode 100644
index 0000000..0410f5f
--- /dev/null
+++ b/Game/Root.cs.uid
@@ -0,0 +1 @@
+uid://bvjvojer872c8
diff --git a/Game/level.tscn b/Game/level.tscn
new file mode 100644
index 0000000..ac8cb9c
--- /dev/null
+++ b/Game/level.tscn
@@ -0,0 +1,144 @@
+[gd_scene format=3 uid="uid://xfd48ep5qi1k"]
+
+[ext_resource type="Script" uid="uid://dk578xnlxjwrr" path="res://Game/LevelServiceSource.cs" id="1_5saw1"]
+[ext_resource type="PackedScene" uid="uid://3v8kq1rfwhmr" path="res://Game/target.tscn" id="1_y7rhs"]
+[ext_resource type="Script" uid="uid://cxfrtglku2gfa" path="res://Game/TargetSpawner.cs" id="2_5saw1"]
+[ext_resource type="Script" uid="uid://bgrjeadxhgmvi" path="res://Game/Score.cs" id="3_spj1u"]
+[ext_resource type="Texture2D" uid="uid://y1ros3y7hag2" path="res://icon.svg" id="4_esqs7"]
+[ext_resource type="Script" uid="uid://ce7gnp1lcrrdv" path="res://Game/Turret.cs" id="5_himod"]
+[ext_resource type="PackedScene" uid="uid://brbufibn7hi3o" path="res://Game/projectile.tscn" id="6_sin03"]
+
+[sub_resource type="Animation" id="Animation_bbjqm"]
+length = 0.001
+tracks/0/type = "value"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("Level/Score:offset_transform_position")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"times": PackedFloat32Array(0),
+"transitions": PackedFloat32Array(1),
+"update": 0,
+"values": [Vector2(0, 0)]
+}
+tracks/1/type = "value"
+tracks/1/imported = false
+tracks/1/enabled = true
+tracks/1/path = NodePath("Level/Score:offset_transform_rotation")
+tracks/1/interp = 1
+tracks/1/loop_wrap = true
+tracks/1/keys = {
+"times": PackedFloat32Array(0),
+"transitions": PackedFloat32Array(1),
+"update": 0,
+"values": [0.0]
+}
+
+[sub_resource type="Animation" id="Animation_4ty2a"]
+resource_name = "wiggle"
+length = 0.3
+tracks/0/type = "value"
+tracks/0/imported = false
+tracks/0/enabled = true
+tracks/0/path = NodePath("Level/Score:offset_transform_position")
+tracks/0/interp = 1
+tracks/0/loop_wrap = true
+tracks/0/keys = {
+"times": PackedFloat32Array(0, 0.049999997, 0.099999994, 0.15, 0.2, 0.24999999),
+"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1),
+"update": 0,
+"values": [Vector2(0, 0), Vector2(10, -10), Vector2(-10, -10), Vector2(10, 10), Vector2(-10, 10), Vector2(0, 0)]
+}
+tracks/1/type = "value"
+tracks/1/imported = false
+tracks/1/enabled = true
+tracks/1/path = NodePath("Level/Score:offset_transform_rotation")
+tracks/1/interp = 1
+tracks/1/loop_wrap = true
+tracks/1/keys = {
+"times": PackedFloat32Array(0, 0.049999997, 0.099999994, 0.15, 0.2, 0.24999999),
+"transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1),
+"update": 0,
+"values": [0.0, 0.08726646, -0.08726646, 0.08726646, -0.08726646, 0.0]
+}
+
+[sub_resource type="AnimationLibrary" id="AnimationLibrary_rckqj"]
+_data = {
+&"RESET": SubResource("Animation_bbjqm"),
+&"wiggle": SubResource("Animation_4ty2a")
+}
+
+[node name="Level" type="Node2D" unique_id=1331632726]
+
+[node name="RootScope" type="Node" parent="." unique_id=1267156771 node_paths=PackedStringArray("_targetSpawner", "InjectedNodes")]
+script = ExtResource("1_5saw1")
+_targetSpawner = NodePath("../Spawner")
+_serviceScope = "GodotHostTest.Game.LevelScope, godot-host-test, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
+InjectedNodes = [NodePath("../Target"), NodePath("../Target2"), NodePath("../Target3"), NodePath("../Target4"), NodePath("../Target5"), NodePath("../Target6"), NodePath("../Score"), NodePath("../Turret")]
+
+[node name="Target" parent="." unique_id=2101558052 instance=ExtResource("1_y7rhs")]
+position = Vector2(198, 143)
+TimeOutDurationInSeconds = 2.0
+
+[node name="Target2" parent="." unique_id=1009901052 instance=ExtResource("1_y7rhs")]
+position = Vector2(669, 64)
+TimeOutDurationInSeconds = 2.0
+
+[node name="Target3" parent="." unique_id=1433382188 instance=ExtResource("1_y7rhs")]
+position = Vector2(1060, 263)
+TimeOutDurationInSeconds = 2.0
+
+[node name="Target4" parent="." unique_id=1553596281 instance=ExtResource("1_y7rhs")]
+position = Vector2(961, 620)
+TimeOutDurationInSeconds = 2.0
+
+[node name="Target5" parent="." unique_id=46923989 instance=ExtResource("1_y7rhs")]
+position = Vector2(469, 591)
+TimeOutDurationInSeconds = 2.0
+
+[node name="Target6" parent="." unique_id=2092179937 instance=ExtResource("1_y7rhs")]
+position = Vector2(53, 417)
+TimeOutDurationInSeconds = 2.0
+
+[node name="Spawner" type="Node2D" parent="." unique_id=251404460 node_paths=PackedStringArray("Timer")]
+script = ExtResource("2_5saw1")
+TimeBetweenSpawns = 1.5
+Timer = NodePath("Timer")
+
+[node name="Timer" type="Timer" parent="Spawner" unique_id=1066078427]
+
+[node name="Score" type="ColorRect" parent="." unique_id=1267746809 node_paths=PackedStringArray("_animationPlayer", "_label")]
+offset_right = 200.0
+offset_bottom = 40.0
+offset_transform_enabled = true
+color = Color(1, 1, 1, 0.2784314)
+script = ExtResource("3_spj1u")
+_animationPlayer = NodePath("../AnimationPlayer")
+_label = NodePath("Label")
+
+[node name="Label" type="Label" parent="Score" unique_id=558063655]
+layout_mode = 1
+anchors_preset = -1
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 10.0
+offset_right = -10.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_colors/font_color = Color(0, 0, 0, 1)
+text = "Score: 0"
+vertical_alignment = 1
+
+[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=1215951318]
+root_node = NodePath("../..")
+libraries/ = SubResource("AnimationLibrary_rckqj")
+
+[node name="Turret" type="Sprite2D" parent="." unique_id=152356196]
+modulate = Color(1.92523e-07, 0.88004553, 0.8802661, 1)
+position = Vector2(539, 307)
+texture = ExtResource("4_esqs7")
+script = ExtResource("5_himod")
+_projectilePackedScene = ExtResource("6_sin03")
+
+[connection signal="timeout" from="Spawner/Timer" to="Spawner" method="OnTimer"]
diff --git a/Interfaces/ILevel.cs.uid b/Interfaces/ILevel.cs.uid
new file mode 100644
index 0000000..d71b971
--- /dev/null
+++ b/Interfaces/ILevel.cs.uid
@@ -0,0 +1 @@
+uid://bahq074tnev8g
diff --git a/addons/GodotDI/Editor/Plugin.cs b/addons/GodotDI/Editor/Plugin.cs
new file mode 100644
index 0000000..9a7a084
--- /dev/null
+++ b/addons/GodotDI/Editor/Plugin.cs
@@ -0,0 +1,170 @@
+#if TOOLS
+using System.Linq;
+using System.Reflection;
+using Godot;
+using Godot.Collections;
+
+namespace GodotHostTest.GodotDI.Editor;
+
+[Tool]
+public partial class Plugin : EditorPlugin
+{
+ ///
+ /// Amount of debounce before updating the injections
+ ///
+ private const double DebounceDelay = 0.1;
+
+ private const BindingFlags InjectBindingFlags =
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+
+ private ScopeInspectorPlugin? _plugin;
+
+ private SceneTreeTimer? _sceneTreeTimer;
+
+ private bool _treeChangesRegistered;
+
+ public override void _EnterTree()
+ {
+ GD.Print("Initializing Godot DI");
+
+ // add the inspector plugin to provide a better interface for the "scope" property
+ _plugin = new ScopeInspectorPlugin();
+ AddInspectorPlugin(_plugin);
+
+ // update injected nodes when the scene changes
+ SceneChanged += OnSceneChanged;
+ }
+
+ public override void _ExitTree()
+ {
+ GD.Print("Deinitializing Godot DI");
+
+ SceneChanged -= OnSceneChanged;
+
+ UnregisterTreeChanges();
+
+ RemoveInspectorPlugin(_plugin);
+ _plugin?.Dispose();
+ _plugin = null;
+ }
+
+ private void OnTreeChanged()
+ {
+ var root = EditorInterface.Singleton.GetEditedSceneRoot();
+ OnSceneChanged(root);
+ }
+
+ private void OnSceneChanged(Node? sceneRoot)
+ {
+ // register to tree changes, in case the registration wasn't possible before
+ RegisterTreeChanges();
+
+ // don't do anything if there's no scene root
+ if (sceneRoot == null) return;
+
+ // apply debounce
+ if (_sceneTreeTimer != null)
+ {
+ _sceneTreeTimer.SetTimeLeft(DebounceDelay);
+ return;
+ }
+
+ _sceneTreeTimer =
+ EditorInterface.Singleton.GetEditedSceneRoot().GetTree().CreateTimer(DebounceDelay);
+ _sceneTreeTimer.Timeout += () => ActualOnSceneChanged(sceneRoot);
+ }
+
+ private void ActualOnSceneChanged(Node sceneRoot)
+ {
+ // mark debounce as completed
+ _sceneTreeTimer = null;
+
+ var sceneNodes = sceneRoot.FindChildren("*");
+ GD.Print($"Updating injection for root {sceneRoot.Name}");
+
+ // look for the service source
+ var serviceSource = sceneNodes.Where(child => child.IsServiceSource())
+ .Single(out var failureReason);
+ switch (failureReason)
+ {
+ case EnumerableExtensions.SingleFailureReason.LessThanOne:
+ return;
+ case EnumerableExtensions.SingleFailureReason.MoreThanOne:
+ GD.PushWarning("Found more than one service source owned by the scene.");
+ return;
+ default:
+ {
+ // find all nodes that need injection
+ var nodesNeedingInjection = sceneNodes.Append(sceneRoot).Where(node =>
+ {
+ var type = node.GetCSharpScriptType();
+ return type != null && (
+ type.GetFields(InjectBindingFlags).Any(field =>
+ field.GetCustomAttributes(typeof(InjectAttribute), true).Length > 0) ||
+ type.GetMethods(InjectBindingFlags).Any(field =>
+ field.GetCustomAttributes(typeof(InjectAttribute), true).Length > 0)
+ );
+ }).ToList();
+
+ // add all nodes needed to the scope
+ var injectedNodes = serviceSource!.Get(ServiceSource.PropertyName.InjectedNodes).AsGodotArray();
+ Array? newArray = null;
+ foreach (var node in nodesNeedingInjection
+ .Where(node => !injectedNodes.Contains(node)))
+ {
+ newArray ??= new Array(injectedNodes);
+ newArray.Add(node);
+ GD.Print($"Added node {node.Name} to injection.");
+ }
+
+ // remove all nodes that are no longer needing injection
+ foreach (var node in injectedNodes
+ .Select(n => (Node?)n.AsGodotObject())
+ .Where(node => !nodesNeedingInjection.Contains(node)))
+ {
+ if (node == null) continue;
+ newArray ??= new Array(injectedNodes);
+ newArray.Remove(node);
+ GD.Print($"Removed node {node?.Name} from injection.");
+ }
+
+ // apply changes to the scene if necessary
+ if (newArray != null)
+ {
+ GD.Print("Apply changes.");
+ var undoRedo = GetUndoRedo();
+ undoRedo.CreateAction("Update injected nodes");
+ undoRedo.AddDoProperty(serviceSource, ServiceSource.PropertyName.InjectedNodes, newArray);
+ undoRedo.AddUndoProperty(serviceSource, ServiceSource.PropertyName.InjectedNodes, injectedNodes);
+ undoRedo.CommitAction();
+ }
+
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Attach to the TreeChanged event of the SceneTree if it's not already registered and if there's a scene tree to
+ /// attach to from the currently edited scene.
+ ///
+ private void RegisterTreeChanges()
+ {
+ if (_treeChangesRegistered) return;
+ var sceneRoot = EditorInterface.Singleton.GetEditedSceneRoot();
+ if (sceneRoot == null) return;
+ var tree = sceneRoot.GetTree();
+ tree.TreeChanged += OnTreeChanged;
+ _treeChangesRegistered = true;
+ }
+
+ private void UnregisterTreeChanges()
+ {
+ var sceneRoot = EditorInterface.Singleton.GetEditedSceneRoot();
+ if (!_treeChangesRegistered || sceneRoot == null) return;
+ var tree = sceneRoot.GetTree();
+ tree.TreeChanged -= OnTreeChanged;
+ SceneChanged -= OnSceneChanged;
+ }
+}
+#endif
\ No newline at end of file
diff --git a/addons/GodotDI/Editor/ScopeInspectorPlugin.cs b/addons/GodotDI/Editor/ScopeInspectorPlugin.cs
new file mode 100644
index 0000000..da37aa8
--- /dev/null
+++ b/addons/GodotDI/Editor/ScopeInspectorPlugin.cs
@@ -0,0 +1,30 @@
+#if TOOLS
+using Godot;
+
+namespace GodotHostTest.GodotDI.Editor;
+
+// it's inside ServiceSource so that it can safely access nameof(_serviceScope)
+public partial class ScopeInspectorPlugin : EditorInspectorPlugin
+{
+ public override bool _CanHandle(GodotObject @object)
+ {
+ var result = @object.IsServiceSource();
+ return result;
+ }
+
+ public override bool _ParseProperty(GodotObject @object, Variant.Type type, string name, PropertyHint hintType,
+ string hintString,
+ PropertyUsageFlags usageFlags, bool wide)
+ {
+ // We handle properties of type integer.
+ if (name != nameof(ServiceSource.PropertyName._serviceScope)) return false;
+ // Create an instance of the custom property editor and register
+ // it to a specific property path.
+ AddPropertyEditor(name, new ScopePickerEditor());
+ // Inform the editor to remove the default property editor for
+ // this property type.
+ return true;
+ }
+}
+
+#endif
\ No newline at end of file
diff --git a/addons/GodotDI/EnumerableExtensions.cs.uid b/addons/GodotDI/EnumerableExtensions.cs.uid
new file mode 100644
index 0000000..29137aa
--- /dev/null
+++ b/addons/GodotDI/EnumerableExtensions.cs.uid
@@ -0,0 +1 @@
+uid://ca5k0qqats56p
diff --git a/addons/GodotDI/GodotDI.cs b/addons/GodotDI/GodotDI.cs
deleted file mode 100644
index cf20750..0000000
--- a/addons/GodotDI/GodotDI.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-#if TOOLS
-using Godot;
-
-namespace GodotHostTest.GodotDI;
-
-[Tool]
-public partial class GodotDI : EditorPlugin
-{
- private ServiceSource.ScopeInspectorPlugin? _plugin;
-
- public override void _EnterTree()
- {
- GD.Print("Initializing Godot DI");
- _plugin = new ServiceSource.ScopeInspectorPlugin();
- AddInspectorPlugin(_plugin);
- }
-
- public override void _ExitTree()
- {
- GD.Print("Deinitializing Godot DI");
- RemoveInspectorPlugin(_plugin);
- _plugin?.Dispose();
- _plugin = null;
- }
-}
-#endif
\ No newline at end of file
diff --git a/addons/GodotDI/ScopeInspectorPlugin.cs b/addons/GodotDI/ScopeInspectorPlugin.cs
deleted file mode 100644
index d8a6b37..0000000
--- a/addons/GodotDI/ScopeInspectorPlugin.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-#if TOOLS
-using Godot;
-using GodotHostTest.addons.godot_di;
-
-namespace GodotHostTest.GodotDI;
-
-public partial class ServiceSource
-{
- // it's inside ServiceSource so that it can safely access nameof(_serviceScope)
- public partial class ScopeInspectorPlugin : EditorInspectorPlugin
- {
- public override bool _CanHandle(GodotObject @object)
- {
- var result = @object.GetScript().AsGodotObject() is CSharpScript script &&
- GodotScriptPathCache.TryGetTypeFromPath(script.ResourcePath, out var type) &&
- type.IsAssignableTo(typeof(ServiceSource));
- return result;
- }
-
- public override bool _ParseProperty(GodotObject @object, Variant.Type type, string name, PropertyHint hintType,
- string hintString,
- PropertyUsageFlags usageFlags, bool wide)
- {
- // We handle properties of type integer.
- if (name != nameof(_serviceScope)) return false;
- // Create an instance of the custom property editor and register
- // it to a specific property path.
- AddPropertyEditor(name, new ScopePickerEditor());
- // Inform the editor to remove the default property editor for
- // this property type.
- return true;
- }
- }
-}
-
-#endif
\ No newline at end of file