Synzza Star - Designing for Portability
My work on Synzza Star so far as been quite tightly coupled with Unity Engine (To those completely unfamiliar with the terms "tight coupling" and "loose coupling", I'll refer you to this excellent plain-language write-up by Ben Koshy). Perhaps that seems like an obvious statement, but in fact many of the action RPG elements I've been designing don't fundamentally need dependencies on the `UnityEngine` scripting API to work.
With the project being in such an early state, I thought it pertinent to do some refactoring work now - to separate the Unity systems from the action RPG systems using abstractions - rather than wait until later when such uncoupling would be more cumbersome.
The Goal
The game project currently has families of systems which are linked in a way borne more out of short-term convenience than long-term planning.
This kind of structure occurs commonly when creating a rapid prototype, where code is simply placed wherever it needs to be to make the game work in the shortest timeframe possible. While this is serviceable, it's not desirable for a project with a lifespan longer than a few weeks.
The goal is to identify the systems which comprise the "brain" of Synzza Star's back-end - for now, mostly the battler AI logic, run-time battler state processing, and data registration logic - and to compartmentalize those systems into their own library which has no dependency on `UnityEngine`.
In the end, Unity will mostly be relegated to a "front-end" and database role. As a front-end component, Unity makes the window appear, handles the rendering pipeline, runs collision and physics calculations, handles the specifics of NavMesh locomotion, and even provides a convenient editor to design UI. As a database component, Unity's Scriptable Objects give me a flexible user-friendly interface to create and link data.
This saves me a lot of time that would be spent on boilerplate tasks that can now be spent refining the other systems which define Synzza Star as a game. Of course, it comes at the cost of needing to create a layer of abstraction which can interface with Unity, but thankfully this isn't that hard to design, and once designed is straightforward to implement. (If the idea of creating an abstraction layer is new to you, feel free to check out this post discussing how to abstract Unity's `Vector3`, `Quaternion`, and `Transform`.)
As before, I won't describe everything I did to this end, as it is extensive and largely not super insightful from a technical standpoint. What we'll discuss are the aspects I found most instructive and/or challenging during this process.
IEnumerable Coroutine Translation Layer
You may remember from one of my previous posts<insert hyperlink here> that I use Unity's Coroutine concept to implement skills that need precise timings. The code shown in that post was using Unity classes and `MonoBehaviour` to define and begin coroutines - this is one such system which I wanted to remove Unity dependencies from.
The trick with doing this is that when Unity is enumerating through a coroutine object, it's expecting to see some special objects which it uses as instructions for how to properly time the coroutine's behavior. Predictably, these are Unity-specific classes. Creating my own objects which mirror the structure of Unity's Wait classes is simple, but that alone won't be enough.
I'll also need a translation layer. If I intend to abstract away Unity's involvement in creating skill coroutines, I need to translate my timing instructions back to these Unity objects in order to let Unity execute the coroutine on my behalf.
With these two components in place, I can now freely create skill coroutines just as I could using `UnityEngine`, while not directly needing that dependency.
`SynzzaGame`, `IWorld`, and `Battle`
When migrating code away from Unity - as with any exodus away from an established order - a deeper, nearly philosophical issue quickly become apparent: the need to properly establish a shared context. When living within Unity, scripts have access to fields from the editor; they can instantiate and destroy other GameObjects with barely any pretext; they can ask Unity to perform a hierarchal search across all active objects in a scene; and they can freely take advantage of Unity Event Functions like `Awake` and `OnCollisionEnter` to assess their status in relation to the game's internal clock. In some ways, these are niceties of Unity's architecture, but they represent a fully realized feature set that comprises a solid foundation for building a game. I will need to provide an interface that can simulate at least some of these features if I want a project that will stay healthy and organized into the future.
It should come to no surprise that my approach to this problem will be iterative. I can't possibly predict all of the functionality I will need across the lifetime of the project, but I can identify what I need right now based on what I've implemented, and then work from there.
What I have thought ahead about is an organizational structure to this functionality that I hope to be relatively stable.
At the foundation, there is the "single singleton" class `SynzzaGame`, which contains many components to prevent the need for additional singletons. Normally I try to stay away from using singletons, but I don't consider them evil enough to not use when one can clearly be helpful. (If you're not convinced, I invite you to check out this excellent article by J.B. Rainsberger on when to use and when not to use singletons.) `SynzzaGame`, for now, contains data registries, a `BattlerCoordinator` instance, and a reference to a "World" object.
The data registries provide easy access to objects like battler factions, spawn location profiles, and other such objects which are registered to the game as a part of initialization. This is data where every instance is unique, read-only, and only needs to be instantiated once.
`BattlerCoordinator` coordinates the battles which are occurring across the current loaded world. Usually there will only be one or no battles, typically involving the player, but the system is open-ended to allow for battles without player participation that could be joined at any time or avoided entirely.
The Battle Coordinator component also represents the iterative nature of this design. Other components that serve equally specific purposes can be added to `SynzzaGame` to consolidate all of the shared resources in one point of contact.
Implementing `IWorld`
Finally, `IWorld` is an interface which represents a "world" object, conceptually a hierarchal tree which can retrieve references to loaded game objects, instantiate new game objects, and destroy discarded game objects.
To be clear, designing something that can implement this behavior on my own would be a considerable task, and that would extend beyond my currently defined scope; therefore, in order to actually implement the `IWorld` interface, I've turned around and simply made an implementation that leverages Unity's native features.
That may seem like a surprising strategy, but remember that my intention was never to stop using Unity. Rather, I endeavored to create an interface that my game could use to communicate with Unity in a way that does not necessitate a dependency on it. Eventually I may want to take a more direct approach to object management, or make an entire engine of my own, but the beauty of abstractions like C# interfaces is that I can still have a functional project without doing that extra work for the time being.
There will be more posts in the future to showcase the iterations to this new framework, but I believe I've covered the most interesting details for now. Until then, take care.
Comments
Post a Comment