πŸ“œ ⬆️ ⬇️

Saving the game in Unity3D

... If you are not writing a kazualka under the web and not a merciless harsh bagel, you cannot do without saving the data to disk.
How is this done in Unity? There are enough options here - there is a PlayerPrefs class in the library, you can serialize objects into XML or binary files, save to * SQL *, you can finally develop your own parser and save format.
Consider in more detail with the first two options, and at the same time try to make the load-save menu with screenshots.

We assume that reading further basic skills with this engine owns. But at the same time, it is possible not to suspect about the existence of PlayerPrefs, GUI in its library, and, in principle, not to know about serialization. With this all and figure it out.
And so that this note does not become too exciting and useful, it is oriented the most irrelevant option in the mobile / tablet / online age - build for Windows (although, of course, more general moments are enough).



')
Another small disclaimer - I am not a pro in any of the topics that are disclosed, so if some things can be done better, easier, more convenient - corrections and additions are very welcome.

1. PlayerPrefs


Convenient built-in class. Works with int, float and string. Quite transparent, but I still met on the forums the turns in the spirit of " I can not understand PlayerPrefs " or "I should somehow deal with PlayerPrefs", so let's look at it with a simple example.

1.1 Primitive use within one scene: QuickSave & QuickLoad for hotkeys.

A quick example of use. Suppose we have one scene and a character on it. The SaveLoad.cs script is attached to the character. We will keep the simplest - his position.

using UnityEngine; using System.Collections; public class SaveLoad : MonoBehaviour { public Transform CurrentPlayerPosition; void Update () { if(Input.GetKeyDown(KeyCode.R)) savePosition(); if(Input.GetKeyDown(KeyCode.L)) if (PlayerPrefs.HasKey("PosX")) // ,       loadPosition(); if(Input.GetKeyDown(KeyCode.D)) PlayerPrefs.DeleteAll(); //       } public void savePosition(){ Transform CurrentPlayerPosition = this.gameObject.transform; PlayerPrefs.SetFloat("PosX", CurrentPlayerPosition.position.x); // ..   PlayerPrefs.SetFloat("PosY", CurrentPlayerPosition.position.y); //   ,   PlayerPrefs.SetFloat("PosZ", CurrentPlayerPosition.position.z); //  float   PlayerPrefs.SetFloat("AngX", CurrentPlayerPosition.eulerAngles.x); PlayerPrefs.SetFloat("AngY", CurrentPlayerPosition.eulerAngles.y); PlayerPrefs.SetString("level", Application.loadedLevelName); //   /  PlayerPrefs.SetInt("level_id", Application.loadedLevel); //   } public void loadPosition(){ Transform CurrentPlayerPosition = this.gameObject.transform; Vector3 PlayerPosition = new Vector3(PlayerPrefs.GetFloat("PosX"), PlayerPrefs.GetFloat("PosY"), PlayerPrefs.GetFloat("PosZ")); Vector3 PlayerDirection = new Vector3(PlayerPrefs.GetFloat("AngX"), //    PlayerPrefs.GetFloat("AngY"), 0); //     CurrentPlayerPosition.position = PlayerPosition; //    CurrentPlayerPosition.eulerAngles = PlayerDirection; } } 


Of course, the use of PlayerPrefs is rather contrived here - in fact, against normal variables, it only adds to us the ability to load the game from the save location after the release.

But the entire main interface of the class is visible: for each of the three types of Get / Set by key, checking entry by key, clearing. It makes no sense even to disassemble ScriptReference, everything is obvious by function name: PlayerPrefs

However, one is still worth looking at in more detail, PlayerPrefs.Save. The description says that in general, the default unit PlayerPrefs writes to the disk only when the application is closed - in general, it is logical, given that the class is not focused on internal data exchange, and on their preservation between sessions. Accordingly, Save () is supposed to be used only for periodic saves in case of crash.

Perhaps in some cases this is how it works. Under Win PlayerPrefs are written to the registry, and, as you can easily see, are read and written immediately.
Something like our class in the registry:



At the end their DJBX33X hash (Bernshtein hash with XOR) is added to all keys.

UnityGraphicsQuality is always saved automatically, and indeed when the application is closed. This is the Quality level from Edit -> Project Settings Quality, which is QualitySettings.SetQualityLevel .

If the application is running, you can modify the saved value in the registry, then request it from the program - and we will see that the modified version has returned. Those. you should not think that while the program is running, PlayerPrefs is something like an analogue of global variables, and the disk does not work.


2. Serialization in XML


We speak serialization, we mean a binary code. This happens , but you can actually serialize into any format. In essence, this is a translation of the data structure or state of the object into a stored / transferred format. And deserialization, respectively - the restoration of the object from the stored / received data.

In general, Mono can both binary serialization and XML (System.Xml.Serialization), but there is one thing: most Unity classes are not serialized directly. It is impossible to simply take and serialize a GameObject, or a class that inherits MonoBehavoir: you will have to create an additional internal serializable class containing the necessary data, and work using it. But XmlSerializer at least automatically eats Vector3, and BinarySerializer, afaik, even does not know how.

2.1 The essence of the example

Imagine that you are writing your Portal , where the hero goes through a series of locations of the same type - but can return to any of them later. And he could have an impact on each: some resources to use, something to break, something to throw out. I want to save these changes, but returning to the location is unlikely and unpredictable, and dragging the parameters of all the rooms in the RAM does not make much sense. We will serialize the location, leaving it - for example, a trigger on the door. And when loading a location, either generate a default situation or, if there is saved data, restore it by it.

2.2 Serializable classes for data

XmlSerializer can work with classes, the data in which consist of other serializable classes, simple types, most of the elements of Collections [.Generic]. The class must have an empty constructor and public access to all serializable fields.
Some types from the Unity library (like Vector3, containing only three fields) successfully pass this face control, but the majority, especially the more complex ones, are its feils.

Assume that in each room we need to save the states of some arbitrary set of GameObjects. We cannot do this directly. So we need duplicate types.

Create a new script in Standard Assets:

 using UnityEngine; using System.Collections.Generic; using System.Xml.Serialization; using System; [XmlRoot("RoomState")] [XmlInclude(typeof(PositData))] public class RoomState { // ,      [XmlArray("Furniture")] [XmlArrayItem("FurnitureObject")] public List<PositData> furniture = new List<PositData>(); //     public RoomState() { } //   public void AddItem(PositData item) { //   -    furniture.Add(item); //      } public void Update(){ // ,     -  foreach (PositData felt in furniture) //   felt.Update(); } } 


In square brackets are the attributes for managing XML serialization . Here they actually affect only the names of tags in the generated * .xml, and strictly speaking, there is no need for them. But let them be, for clarity :) If for some reason all of a sudden it matters to you what the xml-code will look like, then the capabilities of the attributes are certainly wider .

Next, in the same place, add the base class for items from the list and as many followings from it. Although ... for example, one is enough:

 [XmlType("PositionData")] [XmlInclude(typeof(Lamp))] public class PositData { protected GameObject _inst; //       public GameObject inst { set { _inst = value; } } [XmlElement("Type")] public string Name { get; set; } //      Resourses [XmlElement("Position")] public Vector3 position {get; set; } public PositData() { } public PositData(string name, Vector3 position) { this.Name = name; this.position = position; } public virtual void Estate(){ } //  ""    public virtual void Update(){ //    position = _inst.transform.position; //      } } [XmlType("Lamp")] public class Lamp : PositData // ,  ,    / { [XmlAttribute("Light")] public bool lightOn { get; set; } public Lamp() { } public Lamp(string name, Vector3 position, bool lightOn): base(name, position) { this.lightOn = lightOn; } public override void Estate(){ if (!lightOn) ((Light)(_inst.GetComponentInChildren(typeof(Light)))).enabled = false; } //   ,     Light  public override void Update(){ base.Update(); lightOn = ((Light)_inst.GetComponentInChildren(typeof(Light))).enabled; } // lightOn =    Light   } 


So, serializable classes are ready. Let's now make another class to further simplify the serialization of the created RoomState type.

2.3 Directly Serialization

Also in Standard Assets we will make a class with a couple of static methods that we will use in the future:

 using System.Xml.Serialization; using System; using System.IO; public class Serializator { static public void SaveXml(RoomState state, string datapath){ Type[] extraTypes= { typeof(PositData), typeof(Lamp)}; XmlSerializer serializer = new XmlSerializer(typeof(RoomState), extraTypes); FileStream fs = new FileStream(datapath, FileMode.Create); serializer.Serialize(fs, state); fs.Close(); } static public RoomState DeXml(string datapath){ Type[] extraTypes= { typeof(PositData), typeof(Lamp)}; XmlSerializer serializer = new XmlSerializer(typeof(RoomState), extraTypes); FileStream fs = new FileStream(datapath, FileMode.Open); RoomState state = (RoomState)serializer.Deserialize(fs); fs.Close(); return state; } } 


Here we create the XmlSerializer through the constructor Constructor (Type, Type [])
FileStream open at the address of the transfer transmitted by a specific location.

Using

So, all the auxiliary tools are ready, you can proceed to the room itself. On the object of the room we hang:

 public class RoomGen : MonoBehaviour { private RoomState state; //   private string datapath; //        void Start () { datapath = Application.dataPath + "/Saves/SavedData" + Application.loadedLevel + ".xml"; if (File.Exists(datapath)) //      state = Serializator.DeXml(datapath); //  state  else setDefault(); //    Generate(); //      state } void setDefault(){ state = new RoomState(); // chair, table, lamp -    Resourses state.AddItem(new PositData("chair", new Vector3(15f, 1f, -4f))); state.AddItem(new PositData("chair", new Vector3(10f, 1f, 0f))); state.AddItem(new PositData("table", new Vector3(5f, 1f, 4f))); state.AddItem(new Lamp("lamp", new Vector3(5f, 4f, 4f), true)); } void Generate(){ foreach (PositData felt in state.furniture){ //      felt.inst = Instantiate(Resources.Load(felt.Name), felt.position, Quaternion.identity) as GameObject; //   felt.Estate(); //     } } void Dump() { state.Update(); //   state Serializator.SaveXml(state, datapath); //    } } 


Finally, let's call RoomGen.Dump (). For example, suppose triggers on doors that are child objects of a room (an object with the RoomGen component):

 using UnityEngine; using System.Collections; public string nextRoom; public class Door : MonoBehaviour { void OnTriggerEnter(Collider hit) { if (hit.gameObject.tag == "Player") { SendMessageUpwards("Dump"); Application.LoadLevel(nextRoom); } } } 


That's all. The interaction with objects and the process of changing their state are omitted here, but this is easy to add. For the initial test, you can simply add a couple of stateful functions to hotkeys to the script, or pause and move your hands.

At the first launch, the default option is generated, when the changes are exited, they are dumped into the file, when returning, the last state is restored from the file, including if the application was closed. Works like a charm.

One of the significant drawbacks of XML is that the player can easily change the data. And if in this case there are few who will be interested in the arrangement of scattered chairs, it is better to avoid serialization in XML, while maintaining more significant data for the player. Yes, and in the registry, changing the values ​​is not difficult. In such cases, it is already better to use binary serialization or its own format.

3. Save / Load through the menu


Probably, it would be more relevant to implement the option with the selection / creation of a user and internal automatic saves. If your game requires a serious Save / Load menu, then it is unlikely that you are currently reading this article for the profane.

But I can't wait to see the New Year holidays, when I can finally see my sister and finish off the classic American McGee's Alice in a couple of evenings, so we'll do Save / Load almost like there. With screenshots. At the same time there will be a reason to delve into the GUI, textures and other exciting things.

3.1 Main menu

To make loading and saving through the menu, we, oddly enough, need a menu. Of course, you can make it yourself through objects on the stage, you can use ready-made solutions like NGUI , but for now we will use the GUI from the regular library.


 public class MenuScript : MonoBehaviour { public Texture2D backgroundTexture; //     public const int mainMenuWidth = 200; //      private int menutype = 0; //    -   private bool menuMode = true; //    private bool gameMode = false; //     - // false    Load / New Game private void Awake(){ DontDestroyOnLoad(this); //          } void Update () { if (Input.GetKeyDown(KeyCode.Escape)){ // warning!      : Input.GetKey() if(gameMode) if (menutype == 0 || !menuMode){ //          switchGameActivity(); // /   // (     ) menuMode = !menuMode; } menutype = 0; //         } //       } private void OnGUI(){ if (menuMode){ if(backgroundTexture != null) // ,  GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), backgroundTexture); switch (menutype){ case 0: drawMainMenu(); break; case 1: case 2: drawSaveLoadMenu(); break; } } } private void drawMainMenu(){ GUI.BeginGroup (new Rect (Screen.width / 2 - mainMenuWidth/2, Screen.height / 2 - 180, mainMenuWidth, 240)); if (gameMode) if(GUI.Button(new Rect(0,0, mainMenuWidth,30) , "Resume")){ menuMode = false; switchMouseLook(); } if(GUI.Button(new Rect(0, 40, mainMenuWidth, 30) , "New Game")){ menuMode = false; gameMode = true; Application.LoadLevel("first_scene"); } if (gameMode) if(GUI.Button(new Rect(0, 2*40, mainMenuWidth, 30) , "Save")) menutype = 1; if(GUI.Button(new Rect(0, ((gameMode)? 3 : 2)*40, mainMenuWidth, 30) , "Load")){ menutype = 2; //   "2 + gameMode"? C#, nuff said. if(GUI.Button(new Rect(0, ((gameMode)? 4 : 3)*40, mainMenuWidth, 30) , "Options")){} if(GUI.Button(new Rect(0, ((gameMode)? 5 : 4)*40, mainMenuWidth, 30) , "Quit Game")){ Application.Quit(); } GUI.EndGroup(); } void switchGameActivity(){ //    c,       MouseLook, Camera mk = Camera.main; //     .    MouseLook ml = mk.GetComponent<MouseLook>(); //      if (ml != null) ml.enabled = !ml.enabled; //    } } 




Main menu before and after the game starts

3.2 Draw the load / save menu

The drawSaveLoadMenu () function is already called when menutype> 0, but not yet written. Correct this omission. For now, just learn how to draw our menus and call the actual load / save functions.


 public const int slotsAmount = 10; //   / //   .    , private Texture2D[] saveTexture = new Texture2D[slotsAmount]; //  ,     //,         ,  null private void drawSaveLoadMenu(){ if(GUI.Button(new Rect(Screen.width / 2 - 100, Screen.height * 2/3 + 50, 200, 30) , "Back")) menutype = 0; int slot = GUI.SelectionGrid( new Rect( //      5x2 Screen.width / 2 - Screen.height * 5/9, //   Screen.height/3, //   4:3 Screen.height * 10/9, Screen.height/3 ), -1, //     saveTexture, //  null,     5); //     if (slot >= 0) //      if (menutype == 1) savegame(slot); //      -  else if (menutype == 2 && saveTexture[slot] != null) loadgame (slot); } //       - .       , //          




Menu Load on SelectionGrid - looks no different from the corresponding Save

The main thing that I don’t like about this solution is that the slots in the boot menu that do not contain saves remain relatively active - they only differ in their lack of texture and react to pointing. Therefore, the bonus is a grid with handles; instead of inactive slots, we draw Box, for the active Button.
At the same time, we will add rubberness: the number of slots in a line is set, the size of slots is adjusted to the screen. True, they are already square here, but it will be easy to embed an arbitrary aspect ratio :) Well, at the same time, min / max width / height from GUILayout and other file processing.

 public const int slotsAmount = 10; public const int hN = 5; public const int margin = 20; static private int vN = (int)Mathf.Ceil((float)slotsAmount/hN); private Texture2D[] saveTexture = new Texture2D[slotsAmount]; private int slotSize = ((Screen.width*vN)/(Screen.height*hN) >= 1) ? Screen.height/(vN + 2) : Screen.width/(hN + 2); private void drawSaveLoadMenu(){ GUI.BeginGroup (new Rect ( Screen.width / 2 - (slotSize*hN - margin) / 2, Screen.height / 2 - (slotSize*vN - margin) / 2, slotSize*hN - margin, slotSize*vN + 40)); for (int j = 0; j < vN; j++) for (int i = 0, curr = j*hN; (curr = j*hN + i) < slotsAmount; i++){ if (menutype == 2 && saveTexture[curr] == null) GUI.Box(new Rect(slotSize*i, slotSize*j, slotSize - margin, slotSize - margin), ""); else if(GUI.Button(new Rect(slotSize*i, slotSize*j, slotSize - margin, slotSize - margin), saveTexture[curr])){ if (menutype == 1) savestuff(curr); else if (menutype == 2) loadstuff (curr); } } if(GUI.Button(new Rect(slotSize*hN/2 - 100, slotSize*vN , 200, 30) , "Back")) menutype = 0; GUI.EndGroup(); } 




Menu Load on Button and Box - now empty slots are inactive

3.3 Textures, screenshots

So, since the creation of our menu object, we will hold an array of textures. It takes a little memory and we are guaranteed instant access to it. In fact, there is no special alternative here - do not push the same disk work in onGUI ().

As we have already seen, when creating our menu, we also create an array:
 private Texture2D[] saveTexture = new Texture2D[slotsAmount]; 


We will save not only the information of the saves, but also information about them , or rather, which slots contain the save. How to store - the choice of each, you can parameter 0/1 for each slot, you can string from 0/1, but we will make ugly :) and take the bit vector in int. At what point and how it is saved, we will see later, while just reading.
Add to Start ():
 int gS = PlayerPrefs.GetInt("gamesSaved"); for (int i = 0; i < slotsAmount && gS > 0; i++, gS/=2) if (gS%2 != 0){ saveTexture[i] = new Texture2D(Screen.width/4, Screen.height/4); //    ,      saveTexture[i].LoadImage(System.IO.File.ReadAllBytes(Application.dataPath + "/tb/Slot" + i + ".png")); } //  , ,    ,  . 


Well, actually the most important thing in this question is how to save screenshots? The version of Application.CaptureScreenshot , but there are two tricks at once. First of all, they are saved in full size, and since in the final analysis we need only thumbnails, it is more logical to resize right away. Secondly, we hold the array of textures, will you have to read it again from the disk? Not very cool.

We will call the function of taking and recording a screenshot later, but for now we will select in Coroutine in advance:

 IEnumerator readScreen(int i){ yield return new WaitForEndOfFrame(); //     ,       :) int adjustedWidth = Screen.height * 4/3; // ,       Texture2D tex1 = new Texture2D(adjustedWidth, Screen.height); //        tex1.ReadPixels(new Rect((Screen.width - adjustedWidth)/2, 0, adjustedWidth, Screen.height), 0, 0, true); tex1.Apply(); //     Texture2D tex = new Texture2D(Screen.height/3, Screen.height/4, TextureFormat.RGB24, true); tex.SetPixels(tex1.GetPixels(2)); //        Destroy(tex1); tex.Apply(); saveTexture[i] = tex; //     FileStream fs = System.IO.File.Open(Application.dataPath + "/tb/Slot" + i + ".png", FileMode.Create); BinaryWriter binary = new BinaryWriter(fs); binary.Write(tex.EncodeToPNG()); //    fs.Close(); } 


Unpleasant unresolved moment - the texture from the current session and the texture loaded from the disk vary greatly in quality.
Below to the left are two current sessions, to the right - two from the disk, from previous sessions:


3.4 Actually save load implementation

So, it seems to have dealt with the husk. We learned minimally how to work with the GUI, made a simple main menu, the Save / Load menu, and learned how to work with screenshots.

How to implement the interaction between the objects of the scene, the parameters of which we will save and our menu?

1. (, , ) β€” .

2. GameObject.Find GameObject.FindWithTag β€” / β€” . , β€” , , , / , .

3. < , ! >

. . , , .
. β€” //!, , .

 void savegame(int i) { PlayerPrefs.SetInt("slot" + i + "_Lvl", Application.loadedLevel); //    // ! Interface i = Camera.main.GetComponent<Interface>(); if (i != null) i.save(i); //     PlayerPrefs    menuMode = false; //    switchGameActivity(); //   //    .     . //   ,      . //           PlayerPrefs.SetInt("gamesSaved", PlayerPrefs.GetInt("gamesSaved") | (1 << i)); StartCoroutine(readScreen(i)); //        } 


, -, , -, . , , .

. , . , - :)

 void loadgame(int i) { if (gameMode) //  Load  ,      switchGameActivity(); PlayerPrefs.SetInt("Load", i); //    ,    Application.LoadLevel(PlayerPrefs.GetInt("slot" + i + "_Lvl")); //    menuMode = false; gameMode = true; //  ,      } 


, :

 public class Interface : MonoBehaviour { private Transform CurrentPlayerPosition; public virtual void Start () { int load = PlayerPrefs.GetInt("Load"); // ,     if (load >= 0){ //  -   load(load); PlayerPrefs.SetInt("Load", -1); //  Load } } public virtual void save(int i) { //          CurrentPlayerPosition = this.gameObject.transform.parent.transform; PlayerPrefs.SetFloat("slot" + i + "_PosX", CurrentPlayerPosition.position.x); PlayerPrefs.SetFloat("slot" + i + "_PosY", CurrentPlayerPosition.position.y); PlayerPrefs.SetFloat("slot" + i + "_PosZ", CurrentPlayerPosition.position.z); } public virtual void load(int i) { CurrentPlayerPosition = this.gameObject.transform.parent.transform; //        Vector3 PlayerPosition = new Vector3(PlayerPrefs.GetFloat("slot" + i + "_PosX"), PlayerPrefs.GetFloat("slot" + i + "_PosY"), PlayerPrefs.GetFloat("slot" + i + "_PosZ")); CurrentPlayerPosition.position = PlayerPosition; //   ,  } //     } 


β€ž - β€œ : loadgame() load() , β€” . β€” . load(), β€” , Start() .

Farther. . , :

 public class InterfaceWAng : Interface { public override void Start () { base.Start(); } public override void save(int i) { base.save(i); PlayerPrefs.SetFloat("slot" + i + "_AngX", CurrentPlayerPosition.eulerAngles.x); PlayerPrefs.SetFloat("slot" + i + "_AngY", CurrentPlayerPosition.eulerAngles.y); } public override void load(int i) { base.load(i); Vector3 PlayerDirection = new Vector3(PlayerPrefs.GetFloat("slot" + i + "_AngX"), PlayerPrefs.GetFloat("slot" + i + "_AngY"), 0); CurrentPlayerPosition.eulerAngles = PlayerDirection; } } 


, . PlayerPrefs save() / load(), . For what? 2 -, BinarySerializer.
β€” , , SQLite. , , js , , . , .




:


docs.unity3d.com
wiki.unity3d.com
forum.unity3d.com
answers.unity3d.com
stackoverflow.com

and habr. Thanks to them.
I hope all this will bring benefit to someone, and harm to anyone :)

Source: https://habr.com/ru/post/163071/


All Articles