Part 18: Saving A Game
Details about our Players our now being stored, which is a great improvement since it allows for more personalization (although not much of that is possible just yet). The goal this time is to expand on that and allow a Player to save a game part way through. There is nothing more frustrating than making a whole lot of progress and then having to start from scratch again next time. By the end of this part we will be able to save the entire state of a map. I know, this is only half of the problem, but it is a good place to start. Also, there is no point in trying to load a game if we do not know how to store it - that would be pointless.
Save Menu
We will begin by defining a Save Menu, along with making sure that we can get into and back out of it. The first thing to be done is to modify PauseMenu.cs to allow us to access the Save Menu we are about to create. Update the list of options in SetButtons() to be
buttons = new string[] {"Resume", "Save Game", "Exit Game"};
so that when the Menu is loaded we have the option 'Save Game' showing up. Then add the following case
case "Save Game": SaveGame(); break;
to the switch statement in HandleButton() to allow us to handle what happens when the new button is clicked. We also need to add the method SaveGame() to PauseMenu.cs in order for things to compile.
private void SaveGame() { GetComponent< PauseMenu >().enabled = false; SaveMenu saveMenu = GetComponent< SaveMenu >(); if(saveMenu) { saveMenu.enabled = true; saveMenu.Activate(); } }
This simply cancels the Pause Menu then finds the Save Menu and enables it. We are calling saveMenu.Activate() to allow us to do some extra initialization each time the Save Menu is opened.
With these simple changes in place we should create our Save Menu. In the Scripts folder inside the Menu folder create a new C# script called SaveMenu.cs. Set the file to include the following code as a starting point.
using UnityEngine; using RTS; public class SaveMenu : MonoBehaviour { public GUISkin mySkin, selectionSkin; private string saveName = "NewGame"; void Start () { Activate(); } void Update () { //handle escape key if(Input.GetKeyDown(KeyCode.Escape)) { CancelSave(); } } void OnGUI() { GUI.skin = mySkin; DrawMenu(); //handle enter being hit when typing in the text field if(Event.current.keyCode == KeyCode.Return) StartSave(); //if typing and cancel is hit, nothing happens ... //doesn't appear to be a quick fix either ... } public void Activate() { SelectionList.LoadEntries(PlayerManager.GetSavedGames()); } private void DrawMenu() { float menuHeight = GetMenuHeight(); float groupLeft = Screen.width / 2 - ResourceManager.MenuWidth / 2; float groupTop = Screen.height / 2 - menuHeight / 2; Rect groupRect = new Rect(groupLeft, groupTop, ResourceManager.MenuWidth, menuHeight); GUI.BeginGroup(groupRect); //background box GUI.Box(new Rect(0, 0, ResourceManager.MenuWidth, menuHeight), ""); //menu buttons float leftPos = ResourceManager.Padding; float topPos = menuHeight - ResourceManager.Padding - ResourceManager.ButtonHeight; if(GUI.Button(new Rect(leftPos, topPos, ResourceManager.ButtonWidth, ResourceManager.ButtonHeight), "Save Game")) { StartSave(); } leftPos += ResourceManager.ButtonWidth + ResourceManager.Padding; if(GUI.Button(new Rect(leftPos, topPos, ResourceManager.ButtonWidth, ResourceManager.ButtonHeight), "Cancel")) { CancelSave(); } //text area for player to type new name float textTop = menuHeight - 2 * ResourceManager.Padding - ResourceManager.ButtonHeight - ResourceManager.TextHeight; float textWidth = ResourceManager.MenuWidth - 2 * ResourceManager.Padding; saveName = GUI.TextField(new Rect(ResourceManager.Padding, textTop, textWidth, ResourceManager.TextHeight), saveName, 60); SelectionList.SetCurrentEntry(saveName); GUI.EndGroup(); //selection list, needs to be called outside of the group for the menu string prevSelection = SelectionList.GetCurrentEntry(); float selectionLeft = groupRect.x + ResourceManager.Padding; float selectionTop = groupRect.y + ResourceManager.Padding; float selectionWidth = groupRect.width - 2 * ResourceManager.Padding; float selectionHeight = groupRect.height - GetMenuItemsHeight() - ResourceManager.Padding; SelectionList.Draw(selectionLeft,selectionTop,selectionWidth,selectionHeight,selectionSkin); string newSelection = SelectionList.GetCurrentEntry(); //set saveName to be name selected in list if selection has changed if(prevSelection != newSelection) saveName = newSelection; } private float GetMenuHeight() { return 250 + GetMenuItemsHeight(); } private float GetMenuItemsHeight() { return ResourceManager.ButtonHeight + ResourceManager.TextHeight + 3 * ResourceManager.Padding; } private void StartSave() { SaveGame(); } private void CancelSave() { GetComponent< SaveMenu >().enabled = false; PauseMenu pause = GetComponent< PauseMenu >(); if(pause) pause.enabled = true; } private void SaveGame() { GetComponent< SaveMenu >().enabled = false; PauseMenu pause = GetComponent< PauseMenu >(); if(pause) pause.enabled = true; } }
If we cancel the save we want to return to the Pause Menu, since that is how we got into the Save Menu in the first place. Once we have saved the game we also want to return to the Pause Menu. Nothing actually happens when we click save, but the rest of the behaviour of the Menu is in place already. You will notice that we are going to make use of a SelectionList again (which was introduced last time) to display a list of saved games. Since the SelectionList is a static class we need to make sure that we populate this list (even if that is populating it with an empty list), otherwise we will end up with a weird list of values (at the moment this will be Player names). To get the values we want to enter into the SelectionList we need to add
public static string[] GetSavedGames() { DirectoryInfo directory = new DirectoryInfo("SavedGames" + Path.DirectorySeparatorChar + currentPlayer.Name); FileInfo[] files = directory.GetFiles(); string[] savedGames = new string[files.Length]; for(int i=0; i < files.Length; i++) { string filename = files[i].Name; savedGames[i] = filename.Substring(0, filename.IndexOf(".")); } return savedGames; }
to PlayerManager.cs. Since there are no games so far this should return an empty list.
To check that the Save Menu is working (apart from the actual saving bit) we want to add it to our project. Go to the Player folder and expand the Player prefab (see image below).
Click on the HUD element and add SaveMenu.cs to it. Make sure that this script is disabled (e.g. the checkbox next to the script name is unchecked) so that it does not load as soon as the game starts. If you run your game now you should be able to enter and exit the Save Menu.
Menu Adjustments
Before we move on to saving game details I just want to tidy up a couple of things related to our Menus. The first thing I would like to do is to add an actual Menu button to our game, so that Players can use that (as well as by hitting the Escape key) to open the Pause Menu. This will make things a lot more user friendly for the actual Player. We can do this by adding
int padding = 7; int buttonWidth = ORDERS_BAR_WIDTH - 2 * padding - SCROLL_BAR_WIDTH; int buttonHeight = RESOURCE_BAR_HEIGHT - 2 * padding; int leftPos = Screen.width - ORDERS_BAR_WIDTH / 2 - buttonWidth / 2 + SCROLL_BAR_WIDTH / 2; Rect menuButtonPosition = new Rect(leftPos, padding, buttonWidth, buttonHeight); if(GUI.Button(menuButtonPosition, "Menu")) { Time.timeScale = 0.0f; PauseMenu pauseMenu = GetComponent< PauseMenu >(); if(pauseMenu) pauseMenu.enabled = true; UserInput userInput = player.GetComponent< UserInput >(); if(userInput) userInput.enabled = false; }
to DrawResourceBar() in HUD.cs immediately before the call to
GUI.EndGroup();
The second thing is to make sure that when we return to the Main Menu from a game that we are not presented with the selection Menu that our game starts in. This needs to be handled when the menu is loaded, so add the following code to MainMenu.cs to handle that.
void OnLevelWasLoaded() { Screen.showCursor = true; if(PlayerManager.GetPlayerName() == "") { //no player yet selected so enable SetPlayerMenu GetComponent< MainMenu >().enabled = false; GetComponent< SelectPlayerMenu >().enabled = true; } else { //player selected so enable MainMenu GetComponent< MainMenu >().enabled = true; GetComponent< SelectPlayerMenu >().enabled = false; } }
The third thing is to add an entry to the Main Menu to allow the Player to change who they are playing as. In MainMenu.cs update the list of buttons in SetButtons() to the following
buttons = new string[] {"New Game", "Change Player", "Quit Game"};
then add a new case to the switch statement in HandleButton()
case "Change Player": ChangePlayer(); break;
and add the method ChangePlayer() to open the Select Player Menu.
private void ChangePlayer() { GetComponent< MainMenu >().enabled = false; GetComponent< SelectPlayerMenu >().enabled = true; SelectionList.LoadEntries(PlayerManager.GetPlayerNames()); }
The one extra thing that this method is doing is making sure that the SelectionList contains all of the current players. The final adjustment I want to make is to enable double-clicking on a Player in the SelectionList and automatically loading that Player. This is as easy as adding
if(SelectionList.MouseDoubleClick()) { playerName = SelectionList.GetCurrentEntry(); SelectPlayer(); }
to the top of OnGUI() in SelectPlayerMenu.cs (since SelectionList supports the detection of a double-click on an entry). These are small things, but having them in does make the game feel that little bit more polished.
Start Save
Now it is time to turn our attention to the process of actually saving the game. We want to trigger this process from the Save Menu. However, we do not actually want to handle this within the Save Menu itself. What we are going to do is to create a new Manager to handle all of that for us. Inside the RTS folder create a new C# script called SaveManager.cs. Add the following code to it for now.
using UnityEngine; using Newtonsoft.Json; using System.IO; using System.Collections.Generic; namespace RTS { public static class SaveManager { public static void SaveGame(string filename) { JsonSerializer serializer = new JsonSerializer(); serializer.NullValueHandling = NullValueHandling.Ignore; Directory.CreateDirectory("SavedGames"); char separator = Path.DirectorySeparatorChar; string path = "SavedGames" + separator + PlayerManager.GetPlayerName() + separator + filename + ".json"; using(StreamWriter sw = new StreamWriter(path)) { using(JsonWriter writer = new JsonTextWriter(sw)) { writer.WriteStartObject(); SaveGameDetails(writer); writer.WriteEndObject(); } } } private static void SaveGameDetails(JsonWriter writer) { } } }
Notice that it is in our RTS namespace and that it is going to make use of Json.NET to save the details for us. The call to CreateDirectory() is to make sure that the "Saved Games" folder exists (although it should by this point since we have selected a Player already). It will then create a new JSON file with the specified name in the directory for the current Player. This allows us to easily manage the saved games present for each Player (e.g. showing them in the Save Menu - which we will get to shortly). At the moment this file will just contain an empty JSON object. Sure, this is not very useful, but it will allow us to test things and verify that the correct file is being created.
Of course, having a way to save files is all well and good, but we also need to make sure that this is called in the appropriate place. Add the following call to the top of SaveGame() in SaveMenu.cs.
SaveManager.SaveGame(saveName);
You should be able to test this now. Run your game, select a Player, start a new game, and then choose to save that under a particular name. You should be able to find the newly created file in the directory for that Player found inside the "SavedGames" directory. Also, when you go to save the game again you should see the entry showing up in the SelectionList.
There is one small adjustment now to make to our Save Menu. It would be really good for the value that shows up in the text box for the Save Menu to be the name we last saved the game as. To do this we need to enable the storage of the current game name. We will put it into our ResourceManager, since it is mainly there to help make things look nicer. Add
public static string LevelName { get; set; }
to ResourceManager.cs and then add
ResourceManager.LevelName = saveName;
to SaveGame() in SaveMenu.cs under the call to SaveManager.SaveGame(). Finally, add
if(ResourceManager.LevelName != null && ResourceManager.LevelName != "") saveName = ResourceManager.LevelName;
to Activate() in SaveMenu.cs to make sure that we set it each time the Save Menu is created. By adding this in now it also means that things should show correctly once we add the ability to load games next time.
It would actually also be nice to support double-clicking on an existing saved game to allow us to save using that same name. To allow this simply add
if(SelectionList.MouseDoubleClick()) { saveName = SelectionList.GetCurrentEntry(); StartSave(); }
to the top of OnGUI() in SaveMenu.cs.
Confirm Dialog
Now that we have the ability to save files (basic though it is), and to see those files in a list, it does raise the issue of what to do if a user selects an existing saved game. Obviously we want to be able to allow them to use the same save file (otherwise we will end up with dozens, maybe even hundreds, of files). However, it is entirely possible that someone at some point will accidentally choose the wrong file and want to cancel that selection prior to writing over their precious saved game. Therefore it would be sensible if we could add in some sort of confirmation dialog to make sure that users do not make any silly mistakes. By default Unity does not provide this functionality for us, which is unfortunate. Thankfully it is not that difficult to implement our own solution. Inside the Scripts folder found in the Menu folder create a new C# script called ConfirmDialog.cs and add the following code to it.
using UnityEngine; public class ConfirmDialog { private bool confirming = false, clickYes = false, clickNo = false; private Rect confirmRect; private float buttonWidth = 50, buttonHeight = 20, padding = 10; private Vector2 messageDimensions; public void StartConfirmation() { confirming = true; clickYes = false; clickNo = false; } public void EndConfirmation() { confirming = false; clickYes = false; clickNo = false; } public bool IsConfirming() { return confirming; } public bool MadeChoice() { return clickYes || clickNo; } public bool ClickedYes() { return clickYes; } public bool ClickedNo() { return clickNo; } public void Show(string message) { ShowDialog( message); } public void Show(string message, GUISkin skin) { GUI.skin = skin; ShowDialog(message); } private void ShowDialog(string message) { messageDimensions = GUI.skin.GetStyle("window").CalcSize(new GUIContent(message)); float width = messageDimensions.x + 2 * padding; float height = messageDimensions.y + buttonHeight + 2 * padding; float leftPos = Screen.width / 2 - width / 2; float topPos = Screen.height / 2 - height / 2; confirmRect = new Rect(leftPos, topPos, width, height); confirmRect = GUI.Window(0, confirmRect, Dialog, message); } private void Dialog(int windowID) { float buttonLeft = messageDimensions.x / 2 - buttonWidth - padding / 2; float buttonTop = messageDimensions.y + padding; if(GUI.Button(new Rect(buttonLeft, buttonTop, buttonWidth, buttonHeight), "Yes")) { confirming = false; clickYes = true; } buttonLeft += buttonWidth + padding; if(GUI.Button(new Rect(buttonLeft,buttonTop,buttonWidth,buttonHeight),"No")) { confirming = false; clickNo = true; } } }
This allows us to show a confirmation dialog that contains a custom message, a yes button, and a cancel button. Clicking a button sets the state of the dialog so that from somewhere else (e.g. our Save Menu) we can determine the state of the dialog and respond accordingly. Ideally we would add proper click listeners to the dialog so that we do not need to routinely query it, but that is beyond the needs and scope of this tutorial. We also provide the ability to send a custom skin to the dialog so that we can make it look however we want (rather than using the default settings that Unity provides us with).
To make use of this dialog we need to perform some changes to our Save Menu. First off, change StartSave() in SaveMenu.cs so that it can detect whether we need to confirm things or not.
private void StartSave() { //prompt for override of name if necessary if(SelectionList.Contains(saveName)) confirmDialog.StartConfirmation(); else SaveGame(); }
Next we need to update OnGUI() so that we show the confirmation dialog and respond to it accordingly.
void OnGUI() { if(confirmDialog.IsConfirming()) { string message = "\"" + saveName + "\" already exists. Do you wish to continue?"; confirmDialog.Show(message, mySkin); } else if(confirmDialog.MadeChoice()) { if(confirmDialog.ClickedYes()) SaveGame(); confirmDialog.EndConfirmation(); } else { if(SelectionList.MouseDoubleClick()) { saveName = SelectionList.GetCurrentEntry(); StartSave(); } GUI.skin = mySkin; DrawMenu(); //handle enter being hit when typing in the text field if(Event.current.keyCode == KeyCode.Return) StartSave(); } }
Yes, some of this should probably sit inside Update() rather than OnGUI(). But by doing things this way we can guarantee that the Menu itself is only actually drawn when we want it to be drawn. Finally, modify Update() so that it provides some handling of keyboard input for the confirmation dialog - 'Enter' for yes, 'Escape' for cancel.
void Update () { //handle escape key if(Input.GetKeyDown(KeyCode.Escape)) { if(confirmDialog.IsConfirming()) confirmDialog.EndConfirmation(); else CancelSave(); } //handle enter key in confirmation dialog if(Input.GetKeyDown(KeyCode.Return) && confirmDialog.IsConfirming()) { confirmDialog.EndConfirmation(); SaveGame(); } }
If you look back at the changes we just made to OnGUI() you will see that we are passing the skin being used for the Save Menu to the confirmation dialog. For this to actually work properly we need to update a few properties for that skin. The skin being used is MenuSkin which is found in the Skins folder found in the Menu folder. Click on this to bring it up in the inpsector. The values we want to change are all for the 'Window' element. Set the background image for both 'Normal' and 'OnNormal' to be the bacground image that you are using for your Menu (I am using boxArea.png). Then set Border to (6, 6, 6, 6), Padding to (0, 0, 5, 0), and Alignment to 'Middle Center'. The rest of the values can all be left as they are - though feel free to play around with things to see what they do.
If you run your game now and choose to save your game using an existing save file you should see the confirmation dialog pop up. It should also behave as expected (though it can be a bit hard to tell at this stage).
Save Object Details
There is just one major thing left to do for this part - saving actual details for our game. To do this we need to provide an actual implementation for SaveGameDetails() in SaveManager.cs.
private static void SaveGameDetails(JsonWriter writer) { SaveLighting(writer); SaveTerrain(writer); SaveCamera(writer); SaveResources(writer); SavePlayers(writer); }
We want to assign the saving of various parts of the world to self-contained methods. The important things that we are wanting to save is the lighting present in a map, the terrain for that map, the camera, all of the resources found on a map, and all of the players present on the map. Start by defining SaveLighting().
private static void SaveLighting(JsonWriter writer) { Sun sun = (Sun)GameObject.FindObjectOfType(typeof(Sun)); if(writer == null || sun == null) return; writer.WritePropertyName("Sun"); writer.WriteStartObject(); WriteVector(writer, "Position", sun.transform.position); WriteQuaternion(writer, "Rotation", sun.transform.rotation); WriteVector(writer, "Scale", sun.transform.localScale); writer.WriteEndObject(); }
At the moment we have just the one light in our scene - what we are going to refer to as the Sun. At a later date things might get more complex in terms of lighting in the scene, which is why this has a dedicated method. We want to be able to get a reference to our light, so add a new C# script called Sun.cs to the Resources folder. This is just a wrapper script so having the following code in that file will be fine.
using UnityEngine; public class Sun : MonoBehaviour { //wrapper class for the main light in the scene }
Make sure that you open the main scene for your game and attach this script to the light (called Sun) in the scene. When we go to recreate the Sun (e.g. when loading a scene) we will need a prefab object, so all we really need to care about saving is the Position, Rotation, and Scale of the light. To store these values we want to make use of some helper methods (since these sorts of values will be stored lots). We should create these now in SaveManager.cs.
public static void WriteVector(JsonWriter writer, string name, Vector3 vector) { if(writer == null) return; writer.WritePropertyName(name); writer.WriteStartObject(); writer.WritePropertyName("x"); writer.WriteValue(vector.x); writer.WritePropertyName("y"); writer.WriteValue(vector.y); writer.WritePropertyName("z"); writer.WriteValue(vector.z); writer.WriteEndObject(); } public static void WriteQuaternion(JsonWriter writer, string name, Quaternion quaternion) { if(writer == null) return; writer.WritePropertyName(name); writer.WriteStartObject(); writer.WritePropertyName("x"); writer.WriteValue(quaternion.x); writer.WritePropertyName("y"); writer.WriteValue(quaternion.y); writer.WritePropertyName("z"); writer.WriteValue(quaternion.z); writer.WritePropertyName("w"); writer.WriteValue(quaternion.w); writer.WriteEndObject(); }
Note that by passing in the JsonWriter object to all of these methods we are guaranteeing that we save everything to the same file. This is a good thing to make sure of, especially since we are going to end up calling these helper methods from other classes very soon.
Now that the lighting is all being handled it is time to define SaveTerrain().
private static void SaveTerrain(JsonWriter writer) { //needs to be adapted for terrain once if that gets implemented Ground ground = (Ground)GameObject.FindObjectOfType(typeof(Ground)); if(writer == null || ground == null) return; writer.WritePropertyName("Ground"); writer.WriteStartObject(); WriteVector(writer, "Position", ground.transform.position); WriteQuaternion(writer, "Rotation", ground.transform.rotation); WriteVector(writer, "Scale", ground.transform.localScale); writer.WriteEndObject(); }
As with the lighting we do not have anything complicated for our terrain, but it is not difficult to imagine a scenario where this is vastly different. For now we want to be able to get a reference to the plane that we are using for the ground, so add a new C# script called Ground.cs to the Resources folder. Once again this is just a wrapper script so simply have the following code in that file.
using UnityEngine; public class Ground : MonoBehaviour { //wrapper class for the ground in the scene }
Make sure that you add this script to the plane that we are using as the ground in our main scene. As you can see, we are simply wanting to store the basic properties for the ground, so it turns out that SaveTerrain() is almost identical to SaveLighting() for now. The important difference, of course, is the property name being set for the object that we are creating.
The next method to implement is SaveCamera().
private static void SaveCamera(JsonWriter writer) { if(writer == null) return; writer.WritePropertyName("Camera"); writer.WriteStartObject(); Transform cameraTransform = Camera.mainCamera.transform; WriteVector(writer, "Position", cameraTransform.position); WriteQuaternion(writer, "Rotation", cameraTransform.rotation); WriteVector(writer, "Scale", cameraTransform.localScale); writer.WriteEndObject(); }
It is unlikely that we will ever have more than one camera in our scene. I'm sure that someone could come up with some crazy scenario where it would be useful, but we will not even bother worrying about that here. The important thing is that we make sure that we are storing the details of where the camera currently is and where it is pointing.
With the camera sorted it is time to implement SaveResources().
private static void SaveResources(JsonWriter writer) { Resource[] resources = GameObject.FindObjectsOfType(typeof(Resource)) as Resource[]; if(writer == null || resources == null) return; writer.WritePropertyName("Resources"); writer.WriteStartArray(); foreach(Resource resource in resources) { SaveWorldObject(writer, resource); } writer.WriteEndArray(); }
This is the first method that is substantially different. The standard Resources that are going to be present on our map are not controlled by any Players. They are simply deposits scattered around the map which anyone can access. (The one difference will be power, which we will eventually get to. But since it will be generated from Power Plants belonging to the Player it will be handled by the Player) What we are wanting to do is to find all of the Resources present on the map. Then we want to save the details for each of those Resources. It turns out that actually we are going to want to save details for any WorldObject, so we need to add
public static void SaveWorldObject(JsonWriter writer, WorldObject worldObject) { if(writer == null || worldObject == null) return; writer.WriteStartObject(); worldObject.SaveDetails(writer); writer.WriteEndObject(); }
to SaveManager.cs and then add the following method to WorldObject.cs
public virtual void SaveDetails(JsonWriter writer) { }
We will get back to the implementation for this shortly. It is important to note that we have declared this method as virtual so that child objects can override the behaviour (although they need to make sure that they call the parent version of this before doing anything else). We also need to add
using Newtonsoft.Json;
to the top of WorldObject.cs to make sure that we have can reference the JsonWriter.
The final method that needs to be defined in SaveManager.cs (for now, there is more to come still) is SavePlayers().
private static void SavePlayers(JsonWriter writer) { Player[] players = GameObject.FindObjectsOfType(typeof(Player)) as Player[]; if(writer == null || players == null) return; writer.WritePropertyName("Players"); writer.WriteStartArray(); foreach(Player player in players) { writer.WriteStartObject(); player.SaveDetails(writer); writer.WriteEndObject(); } writer.WriteEndArray(); }
This is almost identical to SaveResources(). The key difference is that rather than finding all of the Resources in the map we are finding all of the Players. Once again we want the Player to handle the storing of it's details, so we need to add
public virtual void SaveDetails(JsonWriter writer) { }
to Player.cs as well as adding
using Newtonsoft.Json;
to the top of Player.cs. If you run your game now and hit save you should notice more details showing in your save file.
Now that we have most of the required architecture in place for saving our game let's continue to define how to store specific things. First off, provide an implementation for SaveDetails() in WorldObject.cs.
public virtual void SaveDetails(JsonWriter writer) { SaveManager.WriteString(writer, "Type", name); SaveManager.WriteString(writer, "Name", objectName); SaveManager.WriteInt(writer, "Id", ObjectId); SaveManager.WriteVector(writer, "Position", transform.position); SaveManager.WriteQuaternion(writer, "Rotation", transform.rotation); SaveManager.WriteVector(writer, "Scale", transform.localScale); SaveManager.WriteInt(writer, "HitPoints", hitPoints); SaveManager.WriteBoolean(writer, "Attacking", attacking); SaveManager.WriteBoolean(writer, "MovingIntoPosition", movingIntoPosition); SaveManager.WriteBoolean(writer, "Aiming", aiming); if(attacking) { //only save if attacking so that we do not end up storing massive numbers for no reason SaveManager.WriteFloat(writer, "CurrentWeaponChargeTime", currentWeaponChargeTime); } if(target != null) SaveManager.WriteInt(writer, "TargetId", target.ObjectId); }
As has already been mentioned, we need a prefab in order to create an object in the world. And so when we go to save an object we only actually need to save details that are not defaults for the prefab. Essentially we are wanting to save the unique state of a particular instance of an object so that when we go to reload it we can restore that state. We are using some new helper methods here, so we need to make sure that all of those get added to SaveManager.cs before we go any further.
public static void WriteString(JsonWriter writer, string name, string entry) { if(writer == null) return; writer.WritePropertyName(name); //make sure no bracketed values get stored (e.g. Tank(Clone) becomes Tank) if(entry.Contains("(")) writer.WriteValue(entry.Substring(0, entry.IndexOf("("))); else writer.WriteValue(entry); } public static void WriteInt(JsonWriter writer, string name, int amount) { if(writer == null) return; writer.WritePropertyName(name); writer.WriteValue(amount); } public static void WriteFloat(JsonWriter writer, string name, float amount) { if(writer == null) return; writer.WritePropertyName(name); writer.WriteValue(amount); } public static void WriteBoolean(JsonWriter writer, string name, bool state) { if(writer == null) return; writer.WritePropertyName(name); writer.WriteValue(state); }
An important thing to notice here is that some of our WorldObjects have a reference to other WorldObjects (e.g. target). We cannot just write this object out in full at this point in time (it causes us to generate invalid JSON). Also, that would not really help us when we go to load details. What we actually need is for each WorldObject to have an id value associated with it. That way we can guarantee that we will be able to point at the correct object. To help achieve this add
public int ObjectId { get; set; }
to the top of WorldObject.cs to provide a safe way to be able to get and set this value. Of course, we also need a way to guarantee that each object in the world has a unique objectId assigned to it. We want this to happen when the map is loaded. Then whenever a WorldObject is created it needs to have a valid id assigned to it. This should be handled by another singleton object (similar to how our GameObjectList works). Create a new C# script in the Resources folder called LevelLoader.cs with the following code in it.
using UnityEngine; using RTS; /** * Singleton that handles loading level details. This includes making sure * that all world objects have an objectId set. */ public class LevelLoader : MonoBehaviour { private static int nextObjectId = 0; private static bool created = false; private bool initialised = false; void Awake() { if(!created) { DontDestroyOnLoad(transform.gameObject); created = true; initialised = true; } else { Destroy(this.gameObject); } } void OnLevelWasLoaded() { if(initialised) { WorldObject[] worldObjects = GameObject.FindObjectsOfType(typeof(WorldObject)) as WorldObject[]; foreach(WorldObject worldObject in worldObjects) { worldObject.ObjectId = nextObjectId++; if(nextObjectId >= int.MaxValue) nextObjectId = 0; } } } }
Unity calls OnLevelWasLoaded() after it calls Awake(). However, for some reason the call to Destroy() does not seem to happen until after OnLevelWasLoaded() has run. So we need the check on initialised to make sure that code only runs once even though only one object persists beyond that point. This code nicely assigns an id value to every object found in the world when the level is loaded. We need to add the following code
unitObject.ObjectId = ResourceManager.GetNewObjectId();
to CreateUnit() in Player.cs immediately after the line
unitObject.SetBuilding(creator);
to make sure that when a new Unit is created it is assigned a valid id value. This requires that we add the method GetNewObjectId() to ResourceManager.cs.
public static int GetNewObjectId() { LevelLoader loader = (LevelLoader)GameObject.FindObjectOfType(typeof(LevelLoader)); if(loader) return loader.GetNewObjectId(); return -1; }
This, in turn, requires that we add
public int GetNewObjectId() { nextObjectId++; if(nextObjectId >= int.MaxValue) nextObjectId = 0; return nextObjectId; }
to LevelLoader.cs to actually get the new id value. Finally, we need to add
tempBuilding.ObjectId = ResourceManager.GetNewObjectId();
to CreateBuilding() in Player.cs immediately before
tempCreator = creator;
to make sure that a newly created Building is also assigned a valid id value. It does not matter that this is a temporary Building that may not last since the id value is going to be unique anyway. We currently do not have a way to randomly create new Resources in our world (e.g. trees growing) so there is no need to assign any other id values at the moment.
Of course, for our LevelLoader to even be used we need to add it to our scene. Open the MainMenu scene and create a new Empty Object and call it LevelLoader. Attach LevelLoader.cs to this empty object. Now whenever we launch the game from our Menu (which is the only way we are going to allow the game to be run anyway) we know that a LevelLoader is going to present.
With that in place it is time to provide the definition of SaveDetails() in Player.cs.
public virtual void SaveDetails(JsonWriter writer) { SaveManager.WriteString(writer, "Username", username); SaveManager.WriteBoolean(writer, "Human", human); SaveManager.WriteColor(writer, "TeamColor", teamColor); SaveManager.SavePlayerResources(writer, resources); SaveManager.SavePlayerBuildings(writer, GetComponentsInChildren< Building >()); SaveManager.SavePlayerUnits(writer, GetComponentsInChildren< Unit >()); }
Once again there are some more methods to add to SaveManager.cs.
public static void WriteColor(JsonWriter writer, string name, Color color) { if(writer == null) return; writer.WritePropertyName(name); writer.WriteStartObject(); writer.WritePropertyName("r"); writer.WriteValue(color.r); writer.WritePropertyName("g"); writer.WriteValue(color.g); writer.WritePropertyName("b"); writer.WriteValue(color.b); writer.WritePropertyName("a"); writer.WriteValue(color.a); writer.WriteEndObject(); } public static void SavePlayerResources(JsonWriter writer, Dictionary< ResourceType, int > resources) { if(writer == null) return; writer.WritePropertyName("Resources"); writer.WriteStartArray(); foreach(KeyValuePair< ResourceType, int > pair in resources) { WriteInt(writer, pair.Key.ToString(), pair.Value); } writer.WriteEndArray(); } public static void SavePlayerBuildings(JsonWriter writer, Building[] buildings) { if(writer == null) return; writer.WritePropertyName("Buildings"); writer.WriteStartArray(); foreach(Building building in buildings) { SaveWorldObject(writer, building); } writer.WriteEndArray(); } public static void SavePlayerUnits(JsonWriter writer, Unit[] units) { if(writer == null) return; writer.WritePropertyName("Units"); writer.WriteStartArray(); foreach(Unit unit in units) { SaveWorldObject(writer, unit); } writer.WriteEndArray(); }
Handling of saving the Resources, Buildings, and Units that the Player has is being passed back to our Save Manager to make sure that all of the extra details (objects, arrays, etc) are generated correctly. If you run your game now and save it you should see that all of the basic details for everything that the each Player controls is now being stored.
We are nearly there. The final things that we need to add are the specific details that our WorldObjects need. For example, a Building has some details that need storing that a Unit does not. We will begin this final phase by providing an implementation of SaveDetails() in Resource.cs.
public override void SaveDetails (JsonWriter writer) { base.SaveDetails (writer); SaveManager.WriteFloat(writer, "AmountLeft", amountLeft); }
We also need to make sure that we add
using Newtonsoft.Json;
to the top of Resource.cs. Next provide an implementation for SaveDetails() in Building.cs.
public override void SaveDetails (JsonWriter writer) { base.SaveDetails (writer); SaveManager.WriteBoolean(writer, "NeedsBuilding", needsBuilding); SaveManager.WriteVector(writer, "SpawnPoint", spawnPoint); SaveManager.WriteVector(writer, "RallyPoint", rallyPoint); SaveManager.WriteFloat(writer, "BuildProgress", currentBuildProgress); SaveManager.WriteStringArray(writer, "BuildQueue", buildQueue.ToArray()); }
This requires us to add another helper method to SaveManager.cs to handle an array of values.
public static void WriteStringArray(JsonWriter writer, string name, string[] values) { if(writer == null) return; writer.WritePropertyName(name); writer.WriteStartArray(); foreach(string v in values) { writer.WriteValue(v); } writer.WriteEndArray(); }
Also make sure that you add
using Newtonsoft.Json;
to the top of Building.cs. Now provide an implementation for SaveDetails() in Unit.cs,
public override void SaveDetails (JsonWriter writer) { base.SaveDetails (writer); SaveManager.WriteBoolean(writer, "Moving", moving); SaveManager.WriteBoolean(writer, "Rotating", rotating); SaveManager.WriteVector(writer, "Destination", destination); SaveManager.WriteQuaternion(writer, "TargetRotation", targetRotation); if(destinationTarget) { WorldObject destinationObject = destinationTarget.GetComponent< WorldObject >(); if(destinationObject) SaveManager.WriteInt(writer, "DestinationTargetId", destinationObject.ObjectId); } }
making sure that you also add
using Newtonsoft.Json;
to the top of Unit.cs. Now that the more specific types are storing details it is time to turn our attention to the last group of objects for which we need to store details - specific Units. First off, add an implementation of SaveDetails() to Tank.cs
public override void SaveDetails (JsonWriter writer) { base.SaveDetails (writer); SaveManager.WriteQuaternion(writer, "AimRotation", aimRotation); }
and make sure to add a reference to
using Newtonsoft.Json;
to the top of Tank.cs. Next add an implementation of SaveDetails() to Worker.cs,
public override void SaveDetails (JsonWriter writer) { base.SaveDetails (writer); SaveManager.WriteBoolean(writer, "Building", building); SaveManager.WriteFloat(writer, "AmountBuilt", amountBuilt); if(currentProject) SaveManager.WriteInt(writer, "CurrentProjectId", currentProject.ObjectId); }
adding the following references
using Newtonsoft.Json; using RTS;
to the top of Worker.cs. Finally add an implementation of SaveDetails() to Harvester.cs
public override void SaveDetails (JsonWriter writer) { base.SaveDetails (writer); SaveManager.WriteBoolean(writer, "Harvesting", harvesting); SaveManager.WriteBoolean(writer, "Emptying", emptying); SaveManager.WriteFloat(writer, "CurrentLoad", currentLoad); SaveManager.WriteFloat(writer, "CurrentDeposit", currentDeposit); SaveManager.WriteString(writer, "HarvestType", harvestType.ToString()); if(resourceDeposit) SaveManager.WriteInt(writer, "ResourceDepositId", resourceDeposit.ObjectId); if(resourceStore) SaveManager.WriteInt(writer, "ResourceStoreId", resourceStore.ObjectId); }
with the reference to
using Newtonsoft.Json;
also being added to the top of Harvester.cs.
And I believe that is it. With all of that code in place you should now be able to run your game, get things going (building, attacking, collecting resources, whatever) and then save all of those details. It is possible (though slow and painstaking) to verify that these details are all being stored in the file (which is one of the reasons that we are using JSON). That brings us to the end of another long part, but I trust that you will agree with me that it has been worthwhile. All of the code for this part can be found on github under the commit for Part 18.