diff --git a/Game/CurrentLevel.cs b/Game/CurrentLevel.cs new file mode 100644 index 0000000..c8ff99a --- /dev/null +++ b/Game/CurrentLevel.cs @@ -0,0 +1,13 @@ +using GodotHostTest.Interfaces; + +namespace GodotHostTest.Game; + +public class CurrentLevel : ICurrentLevel +{ + public int LevelNumber { get; private set; } = 1; + + public void NextLevel() + { + LevelNumber++; + } +} \ No newline at end of file diff --git a/Game/GameServiceSource.cs b/Game/GameServiceSource.cs new file mode 100644 index 0000000..f3209cc --- /dev/null +++ b/Game/GameServiceSource.cs @@ -0,0 +1,19 @@ +using Godot; +using GodotHostTest.GodotDI; +using GodotHostTest.Interfaces; +using OwofGames.GodotLume; + +namespace GodotHostTest.Game; + +public partial class GameServiceSource : RootServiceSource +{ + [Export] private LevelsData _levelsData = null!; + + protected override void Configure(Builder builder) + { + builder + .AddSingleton() + .AddInstance(_levelsData) + .AddValueProvider(); + } +} \ No newline at end of file diff --git a/Game/LevelData.cs b/Game/LevelData.cs new file mode 100644 index 0000000..e977a06 --- /dev/null +++ b/Game/LevelData.cs @@ -0,0 +1,13 @@ +using System; +using Godot; +using GodotHostTest.Interfaces; + +namespace GodotHostTest.Game; + +[GlobalClass] +public partial class LevelData : Resource, ILevel +{ + [Export] public double TimeBetweenSpawns; + + TimeSpan ILevel.TimeBetweenSpawns => TimeSpan.FromSeconds(TimeBetweenSpawns); +} \ No newline at end of file diff --git a/Game/LevelScope.cs b/Game/LevelScope.cs index 3b57c41..295b645 100644 --- a/Game/LevelScope.cs +++ b/Game/LevelScope.cs @@ -2,4 +2,4 @@ using OwofGames.GodotLume; namespace GodotHostTest.Game; -public class LevelScope: RootScope; \ No newline at end of file +public class LevelScope : RootScope; \ No newline at end of file diff --git a/Game/LevelScope.cs.uid b/Game/LevelScope.cs.uid deleted file mode 100644 index 0b14511..0000000 --- a/Game/LevelScope.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d18acsodp1s3i diff --git a/Game/LevelServiceSource.cs b/Game/LevelServiceSource.cs index ed30e05..c4d34ad 100644 --- a/Game/LevelServiceSource.cs +++ b/Game/LevelServiceSource.cs @@ -1,18 +1,16 @@ using Godot; using GodotHostTest.GodotDI; -using GodotHostTest.Interfaces; -using Microsoft.Extensions.DependencyInjection; namespace GodotHostTest.Game; -public partial class LevelServiceSource : RootServiceSource +public partial class LevelServiceSource : ServiceSource { [Export] private TargetSpawner _targetSpawner = null!; - protected override void Configure(IServiceCollection serviceCollection) - { - serviceCollection - .AddSingleton(_targetSpawner) - .AddSingleton(_targetSpawner); - } + // protected override void ConfigureServices(IServiceCollection serviceCollection) + // { + // serviceCollection + // .AddSingleton(_targetSpawner) + // .AddSingleton(_targetSpawner); + // } } \ No newline at end of file diff --git a/Game/LevelsData.cs b/Game/LevelsData.cs new file mode 100644 index 0000000..1ce38e3 --- /dev/null +++ b/Game/LevelsData.cs @@ -0,0 +1,16 @@ +using Godot; +using GodotHostTest.Interfaces; + +namespace GodotHostTest.Game; + +[GlobalClass] +public partial class LevelsData : Resource, ILevelDataProvider +{ + [Export] private LevelData[] _levelDataEntries = []; + + public ILevel GetLevelData(int levelNumber) + { + var entry = _levelDataEntries[levelNumber]; + return entry; + } +} \ No newline at end of file diff --git a/Game/Root.cs b/Game/Root.cs index 18127b4..1ac64be 100644 --- a/Game/Root.cs +++ b/Game/Root.cs @@ -1,15 +1,20 @@ using Godot; using GodotHostTest.GodotDI; +using GodotHostTest.Interfaces; namespace GodotHostTest.Game; public partial class Root : Node2D { + [Inject] private readonly ICurrentLevel _currentLevel = null!; + [Inject] private readonly ILevelDataProvider _levelDataProvider = null!; [Inject] private readonly ISceneInstantiator _sceneInstantiator = null!; [Export] private PackedScene _levelScene = null!; public override void _Ready() { - AddChild(_sceneInstantiator.Instantiate(_levelScene)); + var levelData = _levelDataProvider.GetLevelData(_currentLevel.LevelNumber); + AddChild(_sceneInstantiator.Instantiate(_levelScene, + (IValueSetter levelSetter) => { levelSetter.Set(levelData); })); } } \ No newline at end of file diff --git a/Game/level.tscn b/Game/level.tscn index ac8cb9c..522fe5e 100644 --- a/Game/level.tscn +++ b/Game/level.tscn @@ -71,7 +71,7 @@ _data = { [node name="Level" type="Node2D" unique_id=1331632726] -[node name="RootScope" type="Node" parent="." unique_id=1267156771 node_paths=PackedStringArray("_targetSpawner", "InjectedNodes")] +[node name="LevelScope" 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" diff --git a/Interfaces/ICurrentLevel.cs b/Interfaces/ICurrentLevel.cs new file mode 100644 index 0000000..292a516 --- /dev/null +++ b/Interfaces/ICurrentLevel.cs @@ -0,0 +1,7 @@ +namespace GodotHostTest.Interfaces; + +public interface ICurrentLevel +{ + public int LevelNumber { get; } + public void NextLevel(); +} \ No newline at end of file diff --git a/Interfaces/ILevel.cs b/Interfaces/ILevel.cs index 7f05d86..5d8b597 100644 --- a/Interfaces/ILevel.cs +++ b/Interfaces/ILevel.cs @@ -1,5 +1,8 @@ +using System; + namespace GodotHostTest.Interfaces; public interface ILevel { + public TimeSpan TimeBetweenSpawns { get; } } \ No newline at end of file diff --git a/Interfaces/ILevelProvider.cs b/Interfaces/ILevelProvider.cs new file mode 100644 index 0000000..fcfdc51 --- /dev/null +++ b/Interfaces/ILevelProvider.cs @@ -0,0 +1,6 @@ +namespace GodotHostTest.Interfaces; + +public interface ILevelDataProvider +{ + public ILevel GetLevelData(int levelNumber); +} \ No newline at end of file diff --git a/addons/GodotDI/BuilderExtensions.cs b/addons/GodotDI/BuilderExtensions.cs new file mode 100644 index 0000000..7a4b6dd --- /dev/null +++ b/addons/GodotDI/BuilderExtensions.cs @@ -0,0 +1,18 @@ +using OwofGames.GodotLume; + +namespace GodotHostTest.GodotDI; + +public static class BuilderExtensions +{ + public static Builder AddValueProvider(this Builder builder) + where TSourceScope : RootScope + where TDestinationScope : TSourceScope + { + var valueProvider = new ValueProvider(); + return builder + .AddScoped, IValueSetter, TSourceScope>(Builder.NoDependencies, + _ => valueProvider.Setter) + .AddScoped, IValueGetter, TDestinationScope>(Builder.NoDependencies, + _ => valueProvider.Getter); + } +} \ No newline at end of file diff --git a/addons/GodotDI/Editor/Plugin.cs b/addons/GodotDI/Editor/Plugin.cs index fe5a01f..8f10577 100644 --- a/addons/GodotDI/Editor/Plugin.cs +++ b/addons/GodotDI/Editor/Plugin.cs @@ -77,6 +77,7 @@ public partial class Plugin : EditorPlugin // mark debounce as completed _sceneTreeTimer = null; + // check that the instance is still valid after debouncing (could have been disposed) if (!IsInstanceValid(sceneRoot)) return; @@ -96,6 +97,7 @@ public partial class Plugin : EditorPlugin return; default: { + if (!IsInstanceValid(serviceSource)) return; // find all nodes that need injection var nodesNeedingInjection = sceneNodes.Where(node => { @@ -109,7 +111,7 @@ public partial class Plugin : EditorPlugin }).ToList(); // add all nodes needed to the scope - var injectedNodes = serviceSource!.Get(ServiceSource.PropertyName.InjectedNodes).AsGodotArray(); + var injectedNodes = serviceSource.Get(ServiceSource.PropertyName.InjectedNodes).AsGodotArray(); Array? newArray = null; foreach (var node in nodesNeedingInjection .Where(node => !injectedNodes.Contains(node))) @@ -137,7 +139,8 @@ public partial class Plugin : EditorPlugin var undoRedo = GetUndoRedo(); undoRedo.CreateAction("Update injected nodes"); undoRedo.AddDoProperty(serviceSource, ServiceSource.PropertyName.InjectedNodes, newArray); - undoRedo.AddUndoProperty(serviceSource, ServiceSource.PropertyName.InjectedNodes, injectedNodes); + undoRedo.AddUndoProperty(serviceSource, ServiceSource.PropertyName.InjectedNodes, + new Array(injectedNodes)); undoRedo.CommitAction(); } diff --git a/addons/GodotDI/ISceneInstantiator.cs b/addons/GodotDI/ISceneInstantiator.cs index 972e046..7ca4f38 100644 --- a/addons/GodotDI/ISceneInstantiator.cs +++ b/addons/GodotDI/ISceneInstantiator.cs @@ -1,3 +1,4 @@ +using System; using Godot; namespace GodotHostTest.GodotDI; @@ -5,16 +6,18 @@ namespace GodotHostTest.GodotDI; public interface ISceneInstantiator { /// - /// Instantiate a packed scene, just like , but also looks for a scope between the top level children and triggers the dependency injection mechanism when found. + /// Instantiate a packed scene, just like , but also looks for a scope between the top level children of the instantiated scene and triggers the dependency injection mechanism when found. /// /// The packed scene to instantiate. + /// An optional callback invoked once the scope has been created, but before instantiating the scene. /// The created node, with its dependencies satisfied. - Node Instantiate(PackedScene packedScene); + Node Instantiate(PackedScene packedScene, Delegate? onScopeCreated = null); /// /// Instantiate a packed scene, just like , but also looks for a scope between the top level children and triggers the dependency injection mechanism when found. /// /// The packed scene to instantiate. + /// An optional callback invoked once the scope has been created, but before instantiating the scene. /// The created node, with its dependencies satisfied. - T Instantiate(PackedScene packedScene) where T : Node; + T Instantiate(PackedScene packedScene, Delegate? onScopeCreated = null) where T : Node; } \ No newline at end of file diff --git a/addons/GodotDI/IValueGetter.cs b/addons/GodotDI/IValueGetter.cs new file mode 100644 index 0000000..4787a01 --- /dev/null +++ b/addons/GodotDI/IValueGetter.cs @@ -0,0 +1,6 @@ +namespace GodotHostTest.GodotDI; + +public interface IValueGetter +{ + public T Get(); +} \ No newline at end of file diff --git a/addons/GodotDI/IValueSetter.cs b/addons/GodotDI/IValueSetter.cs new file mode 100644 index 0000000..9663090 --- /dev/null +++ b/addons/GodotDI/IValueSetter.cs @@ -0,0 +1,6 @@ +namespace GodotHostTest.GodotDI; + +public interface IValueSetter +{ + public void Set(T value); +} \ No newline at end of file diff --git a/addons/GodotDI/RootServiceSource.cs b/addons/GodotDI/RootServiceSource.cs index bbf9bdd..aedea9d 100644 --- a/addons/GodotDI/RootServiceSource.cs +++ b/addons/GodotDI/RootServiceSource.cs @@ -4,6 +4,7 @@ using Godot; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OwofGames.GodotHost; +using OwofGames.GodotLume; using OwofGames.GodotLume.Microsoft.DependencyInjection; namespace GodotHostTest.GodotDI; @@ -15,6 +16,8 @@ public abstract partial class RootServiceSource : ServiceSource, ISceneInstantia { private Host? _host; + private ILogger? _logger; + private Host Host { get => _host ?? @@ -23,57 +26,59 @@ public abstract partial class RootServiceSource : ServiceSource, ISceneInstantia set => _host = value; } - private ILogger? _logger; - private ILogger Logger => _logger ??= Host.GetLogger(); + /// + public Node Instantiate(PackedScene packedScene, Delegate? onScopeCreated = null) + { + var node = packedScene.Instantiate(); + var scope = node.GetChildren().OfType().SingleOrDefault(); + if (scope == null) + Logger.LogDebug( + "Instantiated the scene {PackedSceneName} through ISceneInstantiator.Instantiate, but no scope found.", + packedScene.GetName()); + else + scope.ResolveInjectedNodes(Host.ServiceProvider, onScopeCreated); + + return node; + } + + /// + public T Instantiate(PackedScene packedScene, Delegate? onScopeCreated = null) + where T : Node + { + return (T)Instantiate(packedScene, onScopeCreated); + } + public override void _EnterTree() { // create the host Host = new HostBuilder() .SetServiceCollectionBuilder(InnerConfigure) - .SetServiceProviderFactory(new LumeServiceProviderFactory()) + .SetServiceProviderFactory(new LumeServiceProviderFactory(), Configure) .Build(); // resolve nodes - ResolveInjectedNodes(Host.ServiceProvider); + ResolveInjectedNodes(Host.ServiceProvider, null); } private void InnerConfigure(IServiceCollection serviceCollection) { serviceCollection.AddSingleton(this); - Configure(serviceCollection); + ConfigureServices(serviceCollection); } + /// + /// Configure the service collection by adding the registrations your game needs. + /// + /// The builder to enrich with your services. + protected abstract void Configure(Builder builder); + /// /// Configure the service collection by adding the registrations your game needs. /// /// The service collection to enrich. - protected abstract void Configure(IServiceCollection serviceCollection); - - /// - public Node Instantiate(PackedScene packedScene) + protected virtual void ConfigureServices(IServiceCollection serviceCollection) { - var node = packedScene.Instantiate(); - var scope = node.GetChildren().OfType().SingleOrDefault(); - if (scope == null) - { - Logger.LogDebug( - "Instantiated the scene {PackedSceneName} through ISceneInstantiator.Instantiate, but no scope found.", - packedScene.GetName()); - } - else - { - scope.ResolveInjectedNodes(Host.ServiceProvider); - } - - return node; - } - - /// - public T Instantiate(PackedScene packedScene) - where T : Node - { - return (T)Instantiate(packedScene); } } \ No newline at end of file diff --git a/addons/GodotDI/ServiceSource.cs b/addons/GodotDI/ServiceSource.cs index 6b16d28..7be0c5f 100644 --- a/addons/GodotDI/ServiceSource.cs +++ b/addons/GodotDI/ServiceSource.cs @@ -1,7 +1,9 @@ using System; +using System.Linq; using System.Reflection; using Godot; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using OwofGames.GodotLume; namespace GodotHostTest.GodotDI; @@ -13,18 +15,33 @@ public partial class ServiceSource : Node [Export] private string _serviceScope = RootScopeQualifiedName; [Export] protected Node?[] InjectedNodes = []; - internal void ResolveInjectedNodes(IServiceProvider serviceProvider) + internal void ResolveInjectedNodes(IServiceProvider serviceProvider, Delegate? onScopeCreated) { + var logger = serviceProvider.GetRequiredService>(); + // if this service source must also act as a scope, create the scope and use its service provider if (_serviceScope != RootScopeQualifiedName) { + // create the scoped provider + var serviceScope = ScopeTypesAndNames.GetType(_serviceScope); + var scopedProvider = serviceProvider + .GetRequiredService() + .GetScopedProvider(serviceScope); + // convert it also to an IServiceProvider var serviceProviderCreator = serviceProvider.GetRequiredService>(); - var scopedProvider = serviceProvider.GetRequiredService() - .GetScopedProvider(ScopeTypesAndNames.GetType(_serviceScope)); serviceProvider = serviceProviderCreator(scopedProvider); - // serviceProvider = serviceProvider - // .CreateScope() - // .ServiceProvider; + // invoke onScopeCreated, if present + // TODO: replace with a source code generation by turning this method private partial with Action argument, and every invocation site calls the necessary provider.Get<...> to build the argument list + if (onScopeCreated != null) + { + var parameters = onScopeCreated.Method.GetParameters() + .Select(parameterInfo => scopedProvider.Get(parameterInfo.ParameterType)); + onScopeCreated.DynamicInvoke(parameters); + } + } + else if (onScopeCreated != null) + { + logger.LogWarning("onScopeCreated passed during instantiation, but no scoped provider was created."); } // resolve the injected nodes against the chosen service provider @@ -32,6 +49,7 @@ public partial class ServiceSource : Node foreach (var node in InjectedNodes) { if (node == null) continue; + logger.LogTrace("Injecting {Node}.", node.Name); var nodeType = node.GetType(); foreach (var field in nodeType .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) diff --git a/addons/GodotDI/ValueProvider.cs b/addons/GodotDI/ValueProvider.cs new file mode 100644 index 0000000..e6e5a05 --- /dev/null +++ b/addons/GodotDI/ValueProvider.cs @@ -0,0 +1,46 @@ +using System; + +namespace GodotHostTest.GodotDI; + +/// +/// An object that allows communication through scopes, by providing a couple getter/setter that can be injected in +/// different scopes. +/// +/// The type that's been passed through scopes. +/// +public class ValueProvider +{ + private bool _isSet; + private T? _value; + + public ValueProvider() + { + Setter = new SetterImplementation(this); + } + + public IValueSetter Setter { get; } + + public IValueGetter Getter => !_isSet + ? throw new InvalidOperationException($"Value of type {typeof(T)} has not been set yet.") + : new GetterImplementation(_value!); + + private class SetterImplementation(ValueProvider valueProvider) : IValueSetter + { + public void Set(T value) + { + if (valueProvider._isSet) + throw new InvalidOperationException($"A value (of type {typeof(T)}) cannot be set more than once."); + + valueProvider._value = value; + valueProvider._isSet = true; + } + } + + private class GetterImplementation(T value) : IValueGetter + { + public T Get() + { + return value; + } + } +} \ No newline at end of file diff --git a/godot-host-test.sln.DotSettings.user b/godot-host-test.sln.DotSettings.user index bfc71fe..4d36f80 100644 --- a/godot-host-test.sln.DotSettings.user +++ b/godot-host-test.sln.DotSettings.user @@ -4,6 +4,7 @@ True True ForceIncluded + ForceIncluded ForceIncluded /usr/lib/dotnet/dotnet /usr/lib/dotnet/sdk/10.0.109/MSBuild.dll diff --git a/root_scene.tscn b/root_scene.tscn index 0d089b5..8c814eb 100644 --- a/root_scene.tscn +++ b/root_scene.tscn @@ -2,9 +2,14 @@ [ext_resource type="Script" uid="uid://bvjvojer872c8" path="res://Game/Root.cs" id="1_iqet6"] [ext_resource type="PackedScene" uid="uid://xfd48ep5qi1k" path="res://Game/level.tscn" id="3_bbjqm"] +[ext_resource type="Script" uid="uid://bpes6mnv21f2v" path="res://Game/GameServiceSource.cs" id="3_iqet6"] +[ext_resource type="Resource" uid="uid://c4ym7vrpgk4e6" path="res://Game/levels_data.tres" id="4_t2jh2"] -[node name="Node2D" type="Node2D" unique_id=920849082] +[node name="Root" type="Node2D" unique_id=920849082] script = ExtResource("1_iqet6") _levelScene = ExtResource("3_bbjqm") -[node name="Level" parent="." unique_id=1331632726 instance=ExtResource("3_bbjqm")] +[node name="Scope" type="Node" parent="." unique_id=597932533 node_paths=PackedStringArray("InjectedNodes")] +script = ExtResource("3_iqet6") +_levelsData = ExtResource("4_t2jh2") +InjectedNodes = [NodePath("..")]