Code Architecture : Engine Dependencies - IAMColumbia/gp2portfoliogame-JSchoppe GitHub Wiki
This project is built in the Unity Engine, but makes use of abstraction layers for the systems and objects used within the game. This provides the benefits of reusable code, more easily testable code, and ease of porting. Some tradeoffs of this code architecture are that it requires extensive scaffolding per engine environment, can make finding bugs in game engine adapters difficult, and introduces some costs in adapting to engine APIs.
The files in the project are separated into several folders based on their abstraction level. The general structure is as follows:
- Assets
- Google Maps SDK
- Plugins
- Resources
- Scripts
- Scripts
- Game Library [Depends on C#]
- Unity Library [Depends on Unity, Game Library]
- Brute Drive Core [Depends on Game Library]
- Brute Drive Unity [Depends on Unity, Brute Drive Core]
- Google Maps SDK
In the file hierarchy Game Library scripts are abstracted away from any engine and contain no direct dependencies to a game engine. Scripts in the Unity Library consist of base implementations of library classes as well as some utilities specific to Unity. The Brute Drive Core folder contains scripts that do not depend on an an engine; a lot of the game systems are implemented here and later wrapped to fit in the engine in the Brute Drive Unity folder.
The universal inheritance of MonoBehaviour in Unity poses a huge threat to the mobility of code. This creates an interesting question; how do we wrap up this functionality while making the least mess possible? For this game there are a few aspects that need to be wrapped.
These methods are integral to the runtime logic of the game. I decided in this case to solve the problem using a singleton pattern. An interface is used to wrap the engine specific service when it is injected into objects requiring a tick routine:
namespace GameLibrary
{
public delegate void TickListener(float deltaTime);
public interface ITickProvider
{
event TickListener Tick;
}
}
namespace BruteDriveCore.Vehicles
{
public class Vehicle : ITickable
{
protected readonly ITickProvider tickProvider;
public Vehicle(ITickProvider tickProvider)
{
this.tickProvider = tickProvider;
tickProvider.Tick += Tick;
}
public void Tick(float deltaTime)
{
}
}
}
The underlying objects are actually unaware of which update loop they are on. I decided to obfuscate that detail so that it wouldn't be a possible crutch preventing the mobility of code, given that other engines implement tick cycles and routine differently.
Wrapping scene instances is perhaps the most laborious task in this project, and I would think there ought to be a better solution with more research. Anything that exists within the context of the scene or that uses designer places objects requires a wrapper. The pattern for wrapping can be seen below. The idea is that a MonoBehaviour contains inspector details for initializing, and sometimes modifying, the underlying engine instance. The simplified core class can be queried, and if not created and it will use those inspector fields to create the object. You can also see here an example of the manual dependency injection of the update singleton.
namespace BruteDriveUnity.Designer.Vehicles
{
public sealed class VehicleInstance : UnityEditorWrapper<Vehicle>
{
...
public override Vehicle Initialize()
=> new Vehicle(UnityTickService.GetProvider(UnityLoopType.FixedUpdate))
{
AmbientFriction = ambientFriction,
MaxSteerFriction = maxSteerFriction,
SteerDegreesPerSecond = steerDegreesPerSecond,
SteerRadiusFactor = steerRadiusFactor,
MaxSteerAngle = maxSteerDegrees,
ForwardsAcceleration = forwardsAcceleration,
ReverseAcceleration = reverseAcceleration,
ForwardsMaxSpeed = forwardsMaxSpeed,
ReverseMaxSpeed = reverseMaxSpeed,
Health = health,
ImpactResistance = impactResistance,
// It is ok if these fields are null.
// The vehicle can run headless and controlless.
Controller = controller,
Renderer = renderer,
Collider = collider
};
}
}
Another thing that has to be addressed in this code architecture pattern is how these core classes communicate back out to the engine level implementation. Once again this is done through dependency injection. An example of this is the vehicle renderer. The vehicle class will communicate to the renderer object via an interface:
namespace BruteDriveCore.Vehicles
{
public interface IVehicleRenderer
{
Vector2 Position { set; }
Vector2 Forwards { set; }
float SteerAngle { set; }
float Health { set; }
void OnDestroyed();
}
}
namespace BruteDriveUnity.Designer.Vehicles
{
public sealed class VehicleRenderer : MonoBehaviour, IVehicleRenderer
{
...
[SerializeField] private Transform[] turningWheels = default;
...
public float SteerAngle
{
set
{
foreach (Transform wheel in turningWheels)
wheel.SetLocalEulerAngleY(value);
}
}
}
}
One final thing to address is the challenge associated with removing the strong coupling to Unity classes such as Vector2
and Mathf
. This is particularly tricky as we may or may not want to use the engine methods based on whether their is anything we can optimize in our game context. Reimplementing every one of these functions and structs would also be very laborious, creating a huge upfront cost in development time. This was my first time tackling this problem; and I decided to look at it from the perspective of build configurations. I decided to wrap the engine methods I am using. While this does not remove engine dependency, it consolidates it all in one place outside of the gameplay and systems code.
#define ENGINE_UNITY
/*
#define ENGINE_MONOGAME
*/
namespace GameLibrary.Math
{
public partial struct Vector2
{
public float x;
public float y;
...
public partial Vector2 GetNormalized();
...
}
#if ENGINE_UNITY
public partial struct Vector2
{
// These operators handle interoperability.
public static implicit operator Vector2(UnityEngine.Vector2 unityV2)
=> new Vector2(unityV2.x, unityV2.y);
public static implicit operator UnityEngine.Vector2(Vector2 coreV2)
=> new UnityEngine.Vector2(coreV2.x, coreV2.y);
// Adapt existing methods.
public partial Vector2 GetNormalized()
=> ((UnityEngine.Vector2)this).normalized;
}
#endif
}
This pattern provides some fortification for the code mobility, allowing for engine functions to be wrapped under configurations.