Tuesday, March 15, 2016

Unity Project Layout, Part Three: Logical

Apart from having discreet scenes, Unity pretty much has no 'opinion' on the logical organization of your program.

There isn't a lot written on this that I can find (ok, online, maybe there are print books).  Most of the advice is general, but sound: Separating interface from game logic (I'd go further and say you should separate game-state from game logic as well).  Name things clearly.  Etc.

There's a lot on folder structure, but not on game structure.  I'm not necessarily talking about the gameplay code (platformer vs real-time-strategy vs RPG, etc).  What I'm going to ruminate about is the structure around that; handling startup, state,  game data, save data, etc.

And, as always, everyone has there own way that (probably) works for them.  This is simply how the manner I've deal with these issues has evolved.




Game Entry

Most games, most programs, need a certain amount of initialization.  Since everything in Unity has to be in a scene, that predictably leads to a startup scene.  The name's irrelevant, 'startup', 'initialization', whatever.  This scene does, however have to be set up as the number zero in the scenes list under the Build Settings menu item, as seen to the right.  No matter what it is, the scene at the top of the list, number zero, is loaded when your game starts.

Once that scene is loaded you can them programmatically control the flow of your game.

By convention I call that first scene 'Startup', which may seem less than surprising.

In my startup scene I like to keep things very simple.  There are only two objects in the scene, a SceneControl object (StartupSceneControl.cs) and the MasterControl object (MasterControl.cs).

StartupSceneControl.cs does only one thing, wait for the end of frame and then load the scene 'LoadData'.


namespace JumpShip
    {
    public class StartupSceneControl : CustomSceneControlBase
        {

        // Use this for initialization
        protected override void Start()
            {
            //Debug.Log("StartupSceneControl start.");
            base.Start();

            StartCoroutine(StartLoadData());
            }


        private IEnumerator StartLoadData()
            {
            yield return new WaitForEndOfFrame();

            SceneManager.LoadScene("LoadData");            
            }

        }
    }

The MasterControl Class


The MasterControl script instantiated here sets itself as 'Don't Destroy On Load', and so will persist through scene changes unless explicitly destroyed.  This is the key class in our game that will be the central hub.



namespace JumpShip
    {

    public class MasterControl : MonoBehaviour
        {

      ...much omitted...

        public GameData CurrentGame;
        private BaseTurnProcessor defaultTurnProcessor;

        private static MasterControl masterControl;
        public static MasterControl Instance
            {
            get {return masterControl;}
            }

      ...much omitted...

        void Awake()
            {
            DontDestroyOnLoad(this);
            masterControl = this;


            mainLogger = new CompositeLogger();
            mainLogger.Filter.SeverityThreshold = logLevel;

            File.Delete(Application.persistentDataPath + "\\JumpShip.log");
            fileLogger = new FileLogger(Application.persistentDataPath + "\\JumpShip.log");
            mainLogger.AddLogger("file", fileLogger);

            }
        ... further stuff


In its role as the central object in our game, MasterControl holds the current game data, CurrentGame.  This is of course the current state data for the game; we serialize and deserialize this to load and save.

The handy thing about this is that in any script, anywhere, the current game state is simply:

         MasterControl.Instance.CurrentGame

This gives us access to the one, and only one, copy of the current game state.  MasterControl also has LoadGame and SaveGame functions which receive a path, and will serialize or deserialize a GameData object appropriately.  Really it just calls CurrentGame.SerializeGameData or the opposite.

The MasterControl object is also the container for all our 'static' data.  In the case of JumpShip, there's a bunch of constant data that is, well, constant, so I didn't bother putting it in a config file:

        
        public static double KilometersPerLightMinute = 1.799E7; //  17,990,000
        public static double KilometersPerLightSecond = 299792;
        public static double SolLuminosityInWatts = 3.846E26;
        public static float EarthRadiusKm = 6378.0f;  //
        public static float SolRadiusKm = 695700;
        public static float OneG = 9.80f;
        public static double GravitationalConstant = 6.674E-11;
        public static double UniversalGasConstant = 8.33144621;
        public static double EarthMass = 5.97E24;
        public static double OneSolMass = 1.989E30;

MasterControl will also hold all our data loaded from config files, that'll come later in the LoadData scene.

The MasterControl script is how you access turn processing.  As you may have noted above in the MasterControl code there's a TurnProcessor class.  As I've reused basically the same MasterControl class in several different projects, the actual turn processing can be set up via dependency injection.  At this point I don't do that; there's only one so it just creates a TurnProcessor (derived from BaseTurnProcessor) in Start.

MasterControl provides a wrapper around processing turns (as JumpShip is basically a turn based game at heart, albeit with time-variable turns).  You can advance one turn (i.e. clicked 'Next Turn'), or they can automatically recur until stopped, or some event trigger brings them to a stop.


 
     public void ProcessTurn(TurnLength turnLength)
            {
            currentTurnLength = turnLength;

            if (AutoTurns)
                {
                if (!IsAutoTurnRunning)
                    {
                    Debug.Log("StartAutoTurn running");
                    StartCoroutine(ProcessTurnAuto());
                    }
                }
            else
                HandleProcessTurn(currentTurnLength);
            }


        private void HandleProcessTurn(TurnLength turnLength)
            {
            isTurnProcessing = true;
            defaultTurnProcessor.ProcessTurn(CurrentGame, turnLength);
            isTurnProcessing = false;
            }




The final thing to note in MasterControl is about all the setup data that we load.  In the LoadData scene, LoadDataSceneControl triggers the loading of all our setup files:



 
namespace JumpShip
    {
    public class LoadDataSceneControl : CustomSceneControlBase
        {
        public TextMeshProUGUI dataFileLoadLabel;

        // Use this for initialization
        protected override void Start()
            {
            //Debug.Log("LoadDataSceneControl start.");            

            base.Start();

            MasterControl.LoadStartupFiles();

            Messenger.AddListener("LoadStartupDataFilesComplete", OnLoadStartupDataFilesComplete);
            Messenger.AddListener("DataFileLoading", OnDataFileLoading);
            }


        protected override void OnDestroy()
            {
            Messenger.RemoveListener("LoadStartupDataFilesComplete", OnLoadStartupDataFilesComplete);
            Messenger.RemoveListener("DataFileLoading", OnDataFileLoading);
            base.OnDestroy();
            }

        private void OnDataFileLoading(string type)
            {
            string s= string.Format("Loading {0}.", type);
           // Debug.Log(s);
            dataFileLoadLabel.text = s;
            }



        private void OnLoadStartupDataFilesComplete(string directory)
            {
            dataFileLoadLabel.text = "Load complete.";

            //Main default files loaded.
            //We may add support for mod directories
            //later, and so may have to check a 
            //list here.
            if (directory.ToLower() == "default")
                SceneManager.LoadScene("MainMenu");
            
            }

        }
    }

Here, the LoadData scene controller triggers MasterControl.LoadStartupFiles, which simply activates a coroutine to start loading our data.  That coroutine simply processes one data file (there are dozens, and I'm adding more all the time), then sends a message to the sceneControl script so that it can update the progress on-screen (I could add a progress bar, etc).  For example:




           Messenger.Broadcast("DataFileLoading", "StarTypes");
            yield return new WaitForEndOfFrame();

            // Star Types
            file = "StarType.json";
            path = Application.dataPath + GameDataPath + file;
            List starTypes = JumpShip.DataHelper.LoadList(path);
            //Make lookup dictionary
            starTypeBySystemCode = starTypes.ToDictionary(p => p.SystemCode);

            //Generate a 0-100 percentage list for randomization.
            StarType.RandomList = RandomizationHelper.CreateRandomizationList(StarType.ToPercentageChanceList(starTypes));            
            yield return new WaitForEndOfFrame();

This sends a message to the sceneControl script, which displays that it is loading the StarTypes data file.  The file is then processed.

I load all this data in a coroutine, waiting a frame between each, so that things don't completely freeze up and there is some progress indicator on screen.  The loading time of each separate file is negligible, though the whole lot of them might take a second or two.  I could do this in a separate thread (and might make it so if any one data file load was too long), but it isn't currently worth the effort.

Thus far we have the game started, our central management object created, and our setup data loaded.

Next I'll go into the SceneControl script and MasterControl script relationships.

No comments:

Post a Comment