Towards savers for dynamically placed objects (base-building game)

Announcements, support questions, and discussion for the Dialogue System.
Post Reply
NotVeryProfessional
Posts: 145
Joined: Mon Nov 23, 2020 6:35 am

Towards savers for dynamically placed objects (base-building game)

Post by NotVeryProfessional »

I'm working on a base-building game where players can build multiple buildings of the same type, i.e. same prefab. This poses a challenge with the save system as those could have the same name, and thus same key. It also means I can't use the SpawnedObjects savers included as examples.

I've come up with something that works (so far in my tests). I'm fairly certain it is not perfect. So I am sharing it here for two purposes: One, to help out others who find they have the same use case and two, to get feedback and improve it. Who knows, maybe it will even be officially included and supported in a future version (permission to use my code for such purposes granted).

My game is on Steam if you want to study the use case:


So here is what I am doing:

All my buildings are under a single parent. I wrote a saver component for that parent that loops over its children and saves them all. This is based on the SpawnedObjectManager. However, it uses the fact I have this hierarchy structure. First it clears out all children, then it rebuilds them from the save data. Note I also have Peasants living in those houses. This is a pure data class (no MonoBehaviour, no game objects associated) so I need to assign it here.

Here's the code, some more explanations at the end:

Code: Select all



	public class VillageSaver : Saver {

		public Game game;
		public PeasantsInfo peasantInfo;

		[Serializable]
		public class BuildingData {
			public string GUID;
			public string typeName;
			public string prefabName;
			public Vector3 position;
			public Quaternion rotation;

			public bool isFinished;
			public int currentHealth;
			public float elapsedConstructionTime;
		}

		[Serializable]
		public class FamilyData {
			public string lastName;
			public List<PeasantData> membersData = new List<PeasantData>();
			public string livesAtRef;
		}

		[Serializable]
		public class PeasantData {
			public Peasant peasant;
			public string taskRef;
		}


		[Serializable]
		public class VillageData {
			public List<FamilyData> familyList = new List<FamilyData>();
			public List<BuildingData> buildingList = new List<BuildingData>();
		}




		public override string RecordData() {
			Assert.IsNotNull(game);
			VillageData villageData = new VillageData();
			foreach (Transform child in transform) {
				BuildingController BC = child.GetComponent<BuildingController>();
				if (BC != null) {
					BuildingData buildingData = new BuildingData();
					buildingData.GUID = BC.GUID;
					buildingData.typeName = BC.data.name;
					buildingData.prefabName = child.name.Replace("(Clone)", string.Empty);
					buildingData.position = child.position;
					buildingData.rotation = child.rotation;
					buildingData.isFinished = BC.isFinished;
					buildingData.currentHealth = BC.currentHealth;
					if (BC.CB != null) {
						buildingData.elapsedConstructionTime = BC.CB.elapsedTime;
					} else {
						buildingData.elapsedConstructionTime = -1;
					}
					villageData.buildingList.Add(buildingData);
				}
			}

			foreach (Family f in game.Families) {
				FamilyData familyData = new FamilyData();
				familyData.lastName = f.lastName;
				if (f.livesAt == null) {
					familyData.livesAtRef = null;	
				} else {
					familyData.livesAtRef = f.livesAt.GUID;
				}
				foreach (Peasant p in f.members) {
					PeasantData peasantData = new PeasantData();
					peasantData.peasant = p;
					if (p.currentTask == null) {
						peasantData.taskRef = null;
					} else {
						peasantData.taskRef = p.currentTask.getReference();
					}
					familyData.membersData.Add(peasantData);
				}
				
				villageData.familyList.Add(familyData);
			}

			return SaveSystem.Serialize(villageData);
		}

		public override void ApplyData(string m_data) {
			if (string.IsNullOrEmpty(m_data)) return;
			var villageData = SaveSystem.Deserialize<VillageData>(m_data);
			if (villageData == null) return;

			// stuff we need
			GameObject GCO = GameObject.FindGameObjectWithTag("GameController");
			Assert.IsNotNull(GCO, "no GameController found");
			GameController GC = GCO.GetComponent<GameController>();
			Assert.IsNotNull(GC, "GameControler object has no GameController component");

			// clear out the village, so we don't get duplicates
			foreach (Transform child in transform) {
				BuildingController BC = child.GetComponent<BuildingController>();
				if (BC != null) {
					Destroy(child.gameObject);
				}
			}

			for (int i = 0; i < villageData.buildingList.Count; i++) {
				var buildingData = villageData.buildingList[i];
				if (buildingData == null) continue;

				Building b = Globals.Instance.buildingsMasterList.Find(x => x != null && string.Equals(x.name, buildingData.typeName));
				Assert.IsNotNull(b);
				GameObject prefab = b.Prefabs.Find(x => x != null && string.Equals(x.name, buildingData.prefabName));
				if (prefab == null) prefab = b.Prefab();
				
				GameObject building = Instantiate(b.Prefab(), buildingData.position, buildingData.rotation, transform);

				BuildingController BC = building.GetComponent<BuildingController>();
				Assert.IsNotNull(BC);
				BC.loadedFromSaveGame = true;
				BC.GUID = buildingData.GUID;
				BC.isFinished = buildingData.isFinished;
				BC.currentHealth = buildingData.currentHealth;

				if (BC.Ghost!=null) Destroy(BC.Ghost); // we never save ghosts, so we never need this
				if (BC.isFinished == false) {
					BC.CB.elapsedTime = buildingData.elapsedConstructionTime;
					BC.CB.gameObject.SetActive(true);
				}

			}

			// families and peasants
			game.Families = new List<Family>();
			game.Peasants = new List<Peasant>();
			BuildingController[] homes = GetComponentsInChildren<BuildingController>();
			foreach (FamilyData f in villageData.familyList) {
				Debug.Log("restoring family "+f.lastName);
				Family family = new Family();
				family.lastName = f.lastName;
				if (f.livesAtRef != null) {
					foreach (BuildingController home in homes) {
						if (home.GUID == f.livesAtRef) {
							Debug.Log("found home of family "+f.lastName);
							family.moveInto(home);
						}
					}
				}
				game.Families.Add(family);
				
				foreach (PeasantData pd in f.membersData) {
					Peasant peasant = new Peasant(family, pd.peasant.Age);
					family.members.Add(peasant);
					game.Peasants.Add(peasant);
					GC.peasantInfo.AddPeasant(peasant);

					// re-assign to current task, if any
					if (!string.IsNullOrEmpty(pd.taskRef)) {
						Debug.Log("looking for task "+pd.taskRef);
						string[] parts = pd.taskRef.Split('/');
						Assert.IsTrue(parts.Length == 3, "task has not exactly 3 parts: "+pd.taskRef);
						if (parts[0] == "x") {
							// GameController task
							foreach (Transform t in GCO.transform) {
								Debug.Log(t.name+" ? "+parts[1]);
								if (t.name == parts[1]) {
									Debug.Log("found gather task");
									TaskController task = t.GetComponent<TaskController>();
									task.ReturnToTask(peasant);
									break;
								}
							}
						} else {
							// some task on a building
							bool foundBuilding = false;
							foreach (Transform building in transform) {
								BuildingController BC = building.GetComponent<BuildingController>();
								if (BC != null && BC.GUID == parts[0]) {
									Debug.Log("found building "+building.name);
									foundBuilding = true;
									Debug.Log("finished = "+BC.isFinished+" / CT = "+BC.ConstructionTask);
									if (BC.isFinished == false && BC.ConstructionTask != null) {
										// building still under construction, so that's the only task we can assign someone to:
										Debug.Log("assigning to construction");
										BC.ConstructionTask.ReturnToTask(peasant);
									} else {
										foreach (TaskController task in BC.availableTasks) {
											Debug.Log(task.name+" ? "+parts[1]);
											if (task.name == parts[1]) {
												Debug.Log("found building task");
												task.ReturnToTask(peasant);
												break;
											}
										}
									}
									break;
								}
							}
							if (foundBuilding == false) {
								Debug.LogError("couldn't find building for this task!");
							}
						}
					}
				}
			}


			if (SaveSystem.framesToWaitBeforeApplyData == 0) {
				ApplyDataToBuildings();
			} else {
				StartCoroutine(ApplyDataToBuildingsAfterFrames(SaveSystem.framesToWaitBeforeApplyData));
			}
		}

		protected void ApplyDataToBuildings() {
			foreach (Transform building in transform) {
				BuildingController BC = building.GetComponent<BuildingController>();
				if (BC != null) {
					foreach (var saver in building.GetComponentsInChildren<Saver>()) {
						saver.ApplyData(SaveSystem.currentSavedGameData.GetData(saver.key));
					}
				}
			}
		}

		protected IEnumerator ApplyDataToBuildingsAfterFrames(int numFrames) {
			for (int i = 0; i < numFrames; i++) {
				yield return null;
			}
			ApplyDataToBuildings();
		}


	}

As you can see, I rely on a class called BuildingController which all of my buildings have. The important part for the saver is that it has this:

Code: Select all

		public string GUID;

...

		void Start() {
			if (string.IsNullOrEmpty(GUID)) {
				GUID = System.Guid.NewGuid().ToString();
			}		

Using this unique ID, I can have multiple buildings of the same type and even with the same name. One possible improvement would be to split that out into its own class and have a UniqueID Monobehaviour or something, that would make it more generic. For my use case, having it in the BuildingController is an easy solution as every building is guaranteed to have one.


For additional, type-specific data, I have additional controllers on my buildings, so I need additional savers for those. For example, one building type is animal pens. They have only one value worth saving: How much food has accumulated. These savers also rely on the BuildingController's GUID so I overwrite how the key is generated and otherwise it's a fairly straightforward saver and looks like this:

Code: Select all

	[RequireComponent(typeof(BuildingController))]
	[RequireComponent(typeof(AnimalPenController))]
	public class AnimalPenSaver : Saver {

		[System.Serializable]
		public class AnimalData {
			public float foodGain = 0;
		}
		AnimalData m_data = new AnimalData();

		/// <summary>
		/// Save data under this key. use BC.GUID + savertype
		/// </summary>
		public override string key {
			get {
				BuildingController BC = GetComponent<BuildingController>();
				Assert.IsNotNull(BC, "BuildingController not found for field saver");
				m_runtimeKey = BC.GUID;
				var typeName = GetType().Name;
				if (typeName.EndsWith("Saver")) typeName.Remove(typeName.Length - "Saver".Length);
				m_runtimeKey += typeName;
				return m_runtimeKey;
			}
			set { } // ignore
		}		

		public override string RecordData() {
			AnimalPenController pen = GetComponent<AnimalPenController>();
			Assert.IsNotNull(pen, "animal pen not found for saver");
			m_data.foodGain = pen.foodGain;
			return SaveSystem.Serialize(m_data);
		}


		public override void ApplyData(string s) {
			if (string.IsNullOrEmpty(s)) return;
			AnimalPenController pen = GetComponent<AnimalPenController>();
			Assert.IsNotNull(pen, "animal pen not found for saver");

			m_data = new AnimalData();

			var data = SaveSystem.Deserialize<AnimalData>(s, m_data);
			if (data == null) return;			
			pen.foodGain = data.foodGain;
		}

	}
And that's all there is to it. Took me quite a while of fiddling to get it working, but it does. The resulting savefile data looks like this:

Code: Select all



    \"buildingList\": [
        {
            \"GUID\": \"1ce06f16-134e-4ece-a526-a5ef467d66ab\",
            \"typeName\": \"Chickens\",
            \"prefabName\": \"Chicken Pen\",
            \"position\": {
                \"x\": -18.299999237060548,
                \"y\": 0.0,
                \"z\": -30.3700008392334
            },
            \"rotation\": {
                \"x\": 0.0,
                \"y\": -0.6573619246482849,
                \"z\": 0.0,
                \"w\": 0.7535750269889832
            },
            \"isFinished\": true,
            \"currentHealth\": 10,
            \"elapsedConstructionTime\": -1.0
        },
        
...

        {
            "key": "1ce06f16-134e-4ece-a526-a5ef467d66abAnimalPenSaver",
            "sceneIndex": -1,
            "data": "{
    \"foodGain\": 7.0
}"
        },        

You can see the GUID + savertype there which ensures the correct saver data is applied to the correct saver on the correct object.


I'm pretty sure my code is clumsy and very far from elegant. I'm still not a good C# coder. So here's to hoping that with the help of a few others with similar needs, we can turn this into something cool and generic that works better.

For example, I'm not happy with having to expose foodGain as a public variable just to make saving it possible. I have other buildings that are more complex. There I'm experimenting with having an internal data class, like this:

Code: Select all


		[System.Serializable]
		public class FieldData {
			public int growDays = 0;
			public int prevDay = 0;
			public float foodGain = 0;
			public float water = 0;
		}
		public FieldData data = new FieldData(); // FIXME: I don't actually want this public, but the saver needs it (or I write get/set functions which is essentially the same

This is for my wheat and cabbage, etc. fields, which needs more data. This class is defined inside the FieldController and storing the save-data in its own class allows me to expose (and serialize/de-serialize) it directly. I have no idea which solution is better - data structure inside the functional class (where it's not really needed) or inside the saver (where it results in a lot of a.fieldname = b.fieldname code.
User avatar
Tony Li
Posts: 21981
Joined: Thu Jul 18, 2013 1:27 pm

Re: Towards savers for dynamically placed objects (base-building game)

Post by Tony Li »

Thanks for sharing that! I plan to do something similar with SpawnedObjectManager and SpawnedObjects. Each SpawnedObject will generate a GUID. It will append this GUID to the saver keys on all of its Savers. When loading a saved game, the SpawnedObjectManager will spawn each saved SpawnedObject, restore its GUID, and tell its savers (which have the GUID appended to their keys) to retrieve their data from the save system.

BTW, your game looks great. I see it has lots of very positive reviews, too! May I add it to the Pixel Crushers Games Showcase page?
NotVeryProfessional
Posts: 145
Joined: Mon Nov 23, 2020 6:35 am

Re: Towards savers for dynamically placed objects (base-building game)

Post by NotVeryProfessional »

BTW, your game looks great. I see it has lots of very positive reviews, too! May I add it to the Pixel Crushers Games Showcase page?
Thanks. Yes, feel free to add it. I am using the Save System and the Dialogue System, though the later is mostly for story mode which is still under development (right now there's exactly one dialog that is actually in the game :-) ).
Post Reply