Saving a complex game


Saving your game

I knew that one day I would have to tackle this problem. When I first started making the game I took some very bad design decisions. One of them being not separating my model end view.  This easier said than done, to be honest I still struggle with the concept. But nonetheless if you're curious to see how I achieved it, read on (extremely long post). 

So here is a glimpse of my main class which is the Citizens. This is a MonoBehaviour that every villager receives. It contains everything the person needs to know it's sort of the master script for each villager. 

    //Serializable base values

    public int age;

    public float ageCounter;

    public string citizenName;

    public string lastName;

    public string job;

    public string currentStateString;

    public bool isMale;

    public bool isPregnant = false;

    public int citizenIndex;

    public float hunger = 5.0f; //Hunger level

    public float hungerTimer = 200f; //round time for hunger to go up one point

    public bool isWorking = false;

    private bool isRunner = false;

    public float speed = 150.0f;   

   

    //Variables contained on the GameObject (References picked up on instantiate or change mesh)

    public Arrow ArrowScript;  

    public SkinnedMeshRenderer citizenMesh;

    public Animation citizenAnimation{get; private set;}

   

    //Non-Mono classes most can be serialized directly(instances)  

    public FamilyNode familyNode;  

    public StateMachine FSM;

    public Inventory tempInventory;

    public Attributes attributes;  

    public WS_Positions.PositionType workPosition;

    private CitizenHealth health;  

    public CitizenOpinion opinion;

    public Flags flags;

   

    //MonoBehaviour attached (set in Start())

    public Seeker seeker;

    private CitizenHealth health;  

   

    //References to other scripts only - Cannot be serialized

    public Building buildScript;

    public FieldScript fieldScript;

    public HouseScript houseScriptMain;

    public WorkScript workScript;

    public Transform _myTransform; 

Alright, so when the player decides to save the game, there is a lot of stuff I need to save. Here comes the first issue, you cannot serialize a class that derives from MonoBehaviour. I googled for some solution and found out about Data Transfer Objects. These are basically container classes that can be directly serialized. Here comes the CitizenDTO class! (constructor)

    public CitizenDTO(CitizenAI cs){

        this.position = new SerializableVector3(cs._myTransform.position);

        this.age = cs.age;

        this.ageCounter = cs.ageCounter;

        this.citizenName = cs.citizenName;

        this.lastName = cs.lastName;

        this.job = cs.job;

        this.jobDisposition = cs.jobDisposition;

        this.currentStateString = cs.currentStateString;

        this.isMale = cs.isMale;

        this.isPregnant = cs.isPregnant;

        this.citizenIndex = cs.citizenIndex;

        this.isNoble = cs.isNoble;

        this.hunger = cs.hunger;

        this.hungerTimer = cs.hungerTimer;

        this.isWorking = cs.isWorking;

        this.workPosition = cs.workPosition;

        this.attributes = cs.attributes;

        this.tempInventory = cs.tempInventory.GetSerializable();

        this.opinion = cs.opinion.GetSerializable();

        this.family = cs.familyNode.GetSerializable();

        this.flags = cs.flags;

        this.socialClass = cs.socialClass;

        this.isLeader = cs.isLeader;

        //We'll come back to this one 

        this.sFSM = cs.FSM.GetSerializable();

    }

This class contains only data that can be serialized directly by the BinaryFormatter class. 

Notice some of the classes call a method called GetSerializable(). This is a method I've implemented that will return an actual Data Transfer Object for a class that cannot be directly serialized, either because it derives from Mono, or because it has one or more references to Mono scripts (which also cannot be serialized)

Each villager is given a unique index that is stored in a Dictionnary when the game is loaded. So when I load first I restore all the citizens, then load their data back, and finally I restore the different buildings and reassign all the references. For example here's the code that is called when restoring a house:

    public void Load(HouseDTO hDTO){

        CitizenAI cs;

        locationIndex = hDTO.locationIndex;

        SaveManager.Instance.RegisterLocation(hDTO.locationIndex, gameObject);

        for(int i = 0 ; i < beds ; i++){

            int currentIndex = hDTO.residentsIndex[i];

            if(currentIndex >= 0){

                if(SaveManager.Instance.citizensLookUpForLoad.TryGetValue(currentIndex, out cs)){

                    cs.houseScriptMain = this;

                    resident[i] = cs.gameObject;

                }

            }

        }

        transform.position = hDTO.position.getVector();

        transform.eulerAngles = hDTO.rotation.getVector();

        this.inventory = hDTO.inventory;

        this.stats.BuildingHealth = hDTO.buildingStats.Health;

        if(SaveManager.Instance.citizensLookUpForLoad.TryGetValue(hDTO.buildingStats.ownerIndex, out cs)){

            stats.SetNewOwner(cs.gameObject);

        }

    }

When I save a building, rather than serializing the reference to the citizen that either lives or works there, I simply save it's index. When we load, we get the citizen reference back from that index. 

Once every citizen and building has been restored, I can restore the familly trees but I won't go into details of that just yet as I'm not satisfied with the current implementation. It works but I will definitly rewrite that system eventually. 

Ok so far I have learned that I can use my own Data Transfer Objects to serialize anything I could possibly want! That's great! So I started testing some cases, saving at different stages and seeing how everyone would perform. It worked perfectly, ... or so I thought. 

The action queue - there was one problem. The way my AI works is by iterating over a queue of actions, when an action is done move to the next one, if the queue is empty get a new action queue. I was not saving that queue, so when the game was loaded everyone would just look for something to do. 

So I had this field, it was harvest time, Duncan the farmer was happily harvesting some wheat. When he was loaded (60 units of wheat!!) he made it's way to the nearest storage barn. While he was in transit, I saved and then loaded. Not surprisingly Duncan was just chilling around with 60 units of wheat in his backpack. Eventually he tried harvesting some more, realized he was full and started making it's way back to the barn. 

It became quite clear that I had to save all the actions in queue to prevent stuff like this. Imagine upon loading your game someone is literally 2 meters away from the barn, then goes all the way back to work before coming back. In a game like this it can throw your entire economy down. 

Let's take a look at the StateMachine class (note this is a base class but most of the functionality is kept here, the only difference with the derived class is the AssessNeeds method (Noblemen will not plow the fields for example.) 

public abstract class StateMachine {

    public CitizenAI cScript;

    public List<AIState> Tasks;

   

    public StateMachine(CitizenAI cs){

        cScript = cs;

        Tasks = new List<AIState>(15);

    }

   

    public void DoCurrent(){

        if(Tasks.Count > 0){

            Tasks[0].StateUpdate();

        }

        else{

            AssessNeeds ();

        }

    }

   

    public void AddAction(AIState _task){

        if(Tasks.Count == 0){

            Tasks.Add(_task);

            Tasks[0].SetUp();

        }

        else{

            Tasks.Add(_task);

        }

    }

   

    public void AddActionPriority(AIState _task){

        Tasks.Insert(0, _task);

    }

   

    public void DeQueue(){

        if(Tasks.Count > 0){

            Tasks.RemoveAt(0);

            if(Tasks.Count > 0){

                Tasks[0].SetUp();

            }

            else{

                AssessNeeds();

            }

        }

        else{

            AssessNeeds();

        }

    }

   

    public void ClearTasks(){

        if(Tasks.Count > 0){

            Tasks[0].Exit();

        }

        Tasks.Clear();

    }

       

    public virtual void AssessNeeds(){}

Pretty straightforward no? Now here is the thing, my Tasks list is just a list of Interfaces, further more each and every one of them keeps a reference to the CitizenAI script, which is Mono so I couldn't just directly serialize the list. In comes the ISerializable interface(https://msdn.microsoft.com/en-us/library/system.runtime.serialization.iserializable%28v=vs.110%29.aspx) 

With this, you actually get to decide which part of your class gets serialized using Reflection. For example, let's take a look at a random Task, say DropItemsAtInventoryLocation:

    [System.Serializable]

    public class DropItemAtInventoryLocation : AIState, ISerializable {

        public int[] ItemsToDrop;

        private Iinventory inventory;

        private int InventoryIndex;

        private CitizenAI cs;

       

        public DropItemAtInventoryLocation(){}

       

        public DropItemAtInventoryLocation(int[] itemsToDrop, Iinventory placeToDrop, CitizenAI _cs){

            ItemsToDrop = itemsToDrop;

            inventory = placeToDrop;

            cs = _cs;

        }

       

        #region AIState implementation

        public void SetUp(){}

       

        public void StateUpdate() {

            for(int i = 0 ; i < ItemsToDrop.Length ; i++){

                int item = ItemsToDrop[i];

                int amountToDrop = cs.tempInventory.removeItem(item);

                int amountNotDropped = inventory.addItem(item, amountToDrop);

                cs.tempInventory.addItem(item, amountNotDropped);

            }

            Exit ();

        }

       

        public void Exit(){

            cs.FSM.DeQueue();

        }

       

        public void ResetOwner(CitizenAI _cs){

            this.cs = _cs;

            this.inventory = SaveManager.Instance.GetIinventoryFromIndex(InventoryIndex);

            if(this.inventory == null){

                Debug.Log("Couldn't locate inventory script");

            }

        }

        #endregion

       

        #region ISerializable implementation

        public void GetObjectData (SerializationInfo info, StreamingContext context)

        {

            info.AddValue("items", ItemsToDrop, typeof(int[]));

            info.AddValue("locationIndex", inventory.getIndex(), typeof(int));

        }

       

        public DropItemAtInventoryLocation(SerializationInfo info, StreamingContext context){

            ItemsToDrop = (int[])info.GetValue("items", typeof(int[]));

            InventoryIndex = (int)info.GetValue("locationIndex", typeof(int));

        }

        #endregion

    }

GetObjectData is called before serialization, and the constructor is automatically called upon deserialization. That's why the empty constructor is required. 

Now I can simply serialize the Task list directly in the StateMachine, upon restore the data that I needed gets restored automatically, and I can simply iterate over them with the ResetOwner() method to restore the correct references. Then we're fully loaded and back to where we were when we saved.

The next game I make I will definitely take that into account, it's been a very nice learning experience. I'm sure there are better ways to tackle the problem but I'm actually very proud with what I came up with! 

If you have any questions or feedback please let me know!

Files

Thorpe.zip 279 MB
Aug 18, 2024

Get Thorpe (Alpha build)

Leave a comment

Log in with itch.io to leave a comment.