Kaosumaru said:
But basically, it boils down to “There is a globally available single instance of a given object”
That's true, but the difference is in the creation of the instance and how you access it - Default unity singleton implementation handles its own creation which may be troublesome when you deal with access via interface.
Handling instances creation in root Engine class gives you more control since you can create new instances in specified order/steps (builder pattern, no race conditions) - which also gives you the possibility to introduce dependency injection. Engine class would also decide which concrete type should be instantiated if it's being accessed via interface.
Kaosumaru said:
I usually dislike the creation of interfaces that are only fulfilled by one class
I like to stick to the rules to make things more readable - if any service/manager would be accessible via Interface, then every service/manager should be accessible via Interface, just to keep things organized. Some would say its an overkill but hey I like it that way.
For example I can't imagine how AchievementsManager could not be hidden behind interface since achievements API calls are different on all platforms. Although you could argue that you can use strategy pattern so that AchievementsManager would be static and it could choose which implementation of IAchievementsHandler to use in order to handle achievements for different platforms, so there's always a way out.
Kaosumaru said:
So, if you have a static Engine class, and it provides you with an interface to a manager, like this “Engine.AudioManager.Mute()” - I would say that it's still a Singleton pattern. Difference between “Engine.AudioManager.Mute()” vs “AudioManager.Instance.Mute()” is only in semantics, not in logic.
It would be more like "Engine.GetManager<IAudioManager>().Mute()" vs “AudioManager.Instance.Mute()”.
But if IAudioManager is a private static field in Engine, then its still just accessing static instance but with additional steps, so what's the deal? I think it gives us some more possibilities, the Engine can decide what's the concrete implementation behind IAudioManager. Also IAudioManager does not have to be static if you happen to hate singletons - The Engine can decide if it can create more than one of IAudioManager and how to pass it.
Kaosumaru said:
and it's not dependency injection if “Engine.AudioManager.Mute()” would be globally available
Yes, if you access it globally then it's not dependency injection, but if you treat the Engine as composition root and create new instances of all managers and other stuff, you can pass quite a lot of dependencies via injection at the initialization stage.
For example, if an Enemy needs a reference to IAudioManager, then:
Engine.CreateManager IAudioManager → Engine.CreateManager IEnemyManager(IAudioManager) → IEnemyManager.SpawnEnemy(IAudioManager) → Enemy.Initialize(IAudioManager)
If you stick to the plan, you can minimize the global access via service locator pattern to minimum or even ditch the global GetManager<IManager>() functionality and try to pass everything via dependency injection.
But then the IEnemyManager needs a reference to IAudioManager only because the Enemy will need it and it seems stupid so I don't know what think about it. And only solutions that come to my mind are service locator - Global GetManager<>() and Dependency Injection frameworks like Zenject that use DI containers which I dont know if I like or not.