Towards savers for dynamically placed objects (base-building game)
Posted: Thu Dec 02, 2021 2:59 am
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:
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:
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:
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:
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:
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.
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();
}
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;
}
}
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
}"
},
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