Aprende C# con Unity - Guardando datos

Supongamos que estás haciendo un juego “completo”. Quieres una pantalla de título y todo. Su usuario puede seleccionar el número de jugadores o una configuración de dificultad, etc., y luego … ¿cómo pasa la información a la siguiente escena? O, lo que es más importante (y algo truculento) ¿cómo se puede guardar una sesión de juego y volver a cargarla más tarde? mostraré algunas de las opciones que me gustan.

GitHub Gitlab

Persistencia estática

Para persistir los datos de una escena a otra puede ser tan simple como guardar datos en una variable estática. Para demostrarlo, necesitaremos un poco de configuración. Crea dos escenas, una llamada “Título” y otra llamada “Juego”. En la barra de menú, seleccione “File-> Build Settings” y en el cuadro de diálogo deberá usar el botón “Agregar actual” o arrastrar y soltar ambas escenas desde el panel Proyecto a la lista “Escenas en compilación”.

La siguiente clase de Data Manager es una clase estática con una variable estática, por lo que no necesita ser agregada a su escena, simplemente “funcionará” automáticamente. Utilizo algo llamado “enumeración” de la que no he hablado antes. Por ahora, puede pensar en una enumeración como un entero (porque puede ser emitida desde o hacia un número entero, aunque en realidad es su propio “tipo”) donde cada entrada se nombra para hacer que su código sea más legible. “Fácil” es como el valor entero de ‘0’ y cuenta desde allí.


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public enum Difficulties
	{
		Easy,
		Medium,
		Hard,
		Count
	}

	public static class DataManager 
	{
		public static Difficulties difficulty;
	}
}

Agregue el siguiente script “TitleController” como un componente a la cámara en la escena “Título”. Este script es muy simple: utilicé la GUI heredada para reducir aún más el tiempo de configuración. Todo lo que hace este script es mostrar botones para cada opción de dificultad, y uno para comenzar a jugar el juego. Cada vez que selecciona uno de los botones, una configuración de dificultad se establece en una variable estática en la clase DataManager.


#region Librerias
using UnityEngine;
using UnityEngine.SceneManagement;
#endregion

namespace MoonAntonio
{
	public class TitleController : MonoBehaviour 
	{
		void OnGUI()
		{
			int yPos = 10;
			GUI.Label(new Rect(10, yPos, 200, 30), "Elegir dificultad");
			for (int i = 0; i < (int)Difficulties.Count; ++i)
			{
				yPos += 40;
				Difficulties type = (Difficulties)i;
				if (DataManager.difficulty == type)
					GUI.Label(new Rect(10, yPos, 200, 30), type.ToString());
				else if (GUI.Button(new Rect(10, yPos, 100, 30), type.ToString()))
					DataManager.difficulty = type;
			}
			yPos += 40;
			if (GUI.Button(new Rect(10, yPos, 100, 30), "Play"))
				SceneManager.LoadScene("Juego");
		}
	}
}

Agregue el siguiente script “GameController” como un componente, a la cámara en la escena “Juego”. Este script también es muy simple. Esta vez todo lo que hago es mostrar qué dificultad se ha seleccionado en la escena de Título y proporcionar una opción para Salir de nuevo a esa escena.


#region Librerias
using UnityEngine;
using UnityEngine.SceneManagement;
#endregion

namespace MoonAntonio
{
	public class GameController : MonoBehaviour 
	{
		void OnGUI()
		{
			GUI.Label(new Rect(10, 10, 200, 30), DataManager.difficulty.ToString());
			if (GUI.Button(new Rect(10, 50, 100, 30), "Salir"))
				SceneManager.LoadScene("Titulo");
		}
	}
}

Con la escena de título abierta, presione Reproducir y observe que la selección que hace en la primera pantalla se guarda y aún está disponible en la siguiente escena. Esto funciona porque las variables estáticas nunca saldrán del alcance mientras el programa se esté ejecutando.

Patrón Singleton

Aunque una clase estática funcionó bien para la muestra anterior, no ofrece tantas opciones arquitectónicas como lo haría un objeto (a saber, herencia y polimorfismo). Un singleton es una variación ligeramente diferente, donde por diseño solo se desea que exista una sola instancia de una clase, pero se puede tener más control sobre la creación y la vida útil del objeto, se pueden usar subclases, etc.


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class DataManagerSingleton 
	{
		public static readonly DataManagerSingleton instance = new DataManagerSingleton();
		private DataManagerSingleton() { }
		public Difficulties difficulty;
	}
}

Aquí he modificado la clase DataManagerSingleton para que ya no sea estática. Creo una instancia estática de solo lectura de la clase (para que ninguna otra clase pueda destruirla o reasignarla) y hacer que el constructor sea privado (para que ninguna otra clase pueda instanciar otro objeto). Esto obliga al patrón singleton a ser utilizado como yo pretendía.

Ahora, la lectura y escritura de la configuración de dificultad debe enrrutarse a través de la instancia singleton. La siguiente línea muestra un ejemplo de cómo escribir el valor:


DataManagerSingleton.instance.difficulty = type;

Unity “Singleton”

A veces puede ser conveniente utilizar una clase basada en MonoBehaviour para su persistencia (en caso de que desee aprovechar Coroutines, etc.) En ese caso, puede hacer que GameObjects “sobrevivan” a un cambio de escena utilizando el método “DontDestroyOnLoad”. Tenga en cuenta que esta es también una forma útil de hacer que la música se reproduzca entre los cambios de escena.


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class DataManagerUnitySingleton : MonoBehaviour 
	{
		public static DataManagerUnitySingleton instance
		{
			get
			{
				if (_instance == null)
				{
					GameObject obj = new GameObject("Data Manager Unity Singleton");
					_instance = obj.AddComponent<DataManagerUnitySingleton>();
					DontDestroyOnLoad(obj);
				}
				return _instance;
			}
		}
		static DataManagerUnitySingleton _instance;
		public Difficulties difficulty;
	}
}

Esta versión del DataManagerUnitySingleton hereda de MonoBehaviour. El “Singleton” es creado por algo llamado “Lazy Loading” lo que significa que tan pronto como un script llame a la propiedad “instance”, la clase verá su “getter” y determinará si ha creado uno. De lo contrario, creará un nuevo GameObject, asignará el componente correcto y se asegurará de que GameObject esté marcado correctamente para que no se destruya al cambiar de escena.

Esta versión no es tan “segura” como la versión anterior, porque hay formas en que otros scripts pueden hacer que se destruya el GameObject de su script, y aunque su script “sobreviva” debido a la referencia estática, la comparación con null aún devuelve verdadero porque Unity anula el operador (para tener en cuenta el GameObject), por lo tanto, se crearía otro GameObject/Singleton.

Además, nada impide que otro script agregue copias adicionales de este componente a otros GameObjects, aunque como no proporcioné ningún setter, solo se reconocerá un componente a la vez como instancia principal.

Si quereis ver una muestra de un Singleton para Unity común,  aquí.

Player Prefs

Todos los ejemplos hasta ahora son buenas maneras de tomar datos de una escena a otra, sin embargo, ninguno de ellos puede conservar datos en varias sesiones de juego. Para realizar esta tarea, debe guardar los datos en “disco” de una forma u otra. Unity proporciona una solución conveniente sin necesidad de comprender cómo crear, leer y escribir en archivos. Esta solución se llama PlayerPrefs -  vea la documentación aquí.

Puedes pensar en PlayerPrefs como un diccionario que solo sabe cómo trabajar con una “cadena” para la clave y un “int”, “float” o “string” para el valor. A diferencia de un diccionario genérico, puede mezclar y combinar cualquier combinación de esos valores.

Aquí hay algunos casos de uso:


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class PlayerPrefsExample : MonoBehaviour 
	{
		private void Start()
		{
			// Almacenar un valor entero
			PlayerPrefs.SetInt("Difficulty", 0);
			// Recupere un valor entero (si existe, o use un valor predeterminado de '0' de lo contrario)
			DataManagerUnitySingleton.instance.difficulty = (Difficulties)PlayerPrefs.GetInt("Difficulty", 0);
		}
	}
}

Serializacion

Mencioné la serialización desde el principio: Unity utiliza la serialización para almacenar los valores de sus componentes mientras está en el editor. Desafortunadamente, muchas de las opciones convenientes que te gustaría utilizar, como un Serializador Binario o XML, requerirían que puedas usar un Constructor, no una opción con MonoBehaviour. Además, la jerarquía de objetos, las referencias a objetos y el deseo de persistir en los valores de los componentes nativos de Unity complican enormemente este proceso.

El método que prefiero es la serialización manual en cadenas JSON. Aunque requiere un poco más de configuración que algunas de las otras rutas, creo que tengo muchas más opciones y mantengo un control total sobre el proceso. Tampoco tengo que preocuparme por “versionar” mis datos. Puedo eliminar datos, agregar datos, cambiar tipos de datos, etc. y no hará que el serializador se bloquee, porque puedo controlar el proceso de qué y cómo persisto los datos. Por ejemplo, puedo verificar la presencia de claves y tratar de enviar tipos de datos donde sea necesario. Además, como estoy serializando una cadena JSON, puedo fácilmente persistir el resultado en PlayerPrefs, enviarlo a un servidor o escribirlo en un archivo local como lo desee.

Decidí no volver a inventar la rueda esta vez, y usé un script público para la serialización JSON. Tome una copia  aquí, y agréguelo a su proyecto.

Crea una nueva escena llamada “Demo”. Crea un Cubo (desde la barra de menú elige “GameObject-> Objet 3D-> Cube”) y luego crea y adjunta el siguiente script “Monster” (¿porque no es más divertido trabajar con monstruos?)


#region Librerias
using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
#endregion

namespace MoonAntonio
{
	public class Monster : MonoBehaviour 
	{
		public int HP;

		public virtual Dictionary<string, object> Save()
		{
			Dictionary<string, object> data = new Dictionary<string, object>();
			data.Add("HP", HP);
			data.Add("X", transform.localPosition.x);
			data.Add("Y", transform.localPosition.y);
			data.Add("Z", transform.localPosition.z);
			data.Add("Prefab", gameObject.name);
			return data;
		}

		public virtual void Load(Dictionary<string, object> data)
		{
			HP = Convert.ToInt32(data["HP"]);
			float x = Convert.ToSingle(data["X"]);
			float y = Convert.ToSingle(data["Y"]);
			float z = Convert.ToSingle(data["Z"]);
			transform.localPosition = new Vector3(x, y, z);
		}
	}
}

Este script tiene un solo campo para “HP” - puntos de golpe. En realidad, no hace nada, pero quiero mostrar que podremos guardar tanto una variable local como valores del componente de transformación. Los métodos de Guardar y Cargar sirven (con suerte). Envuelven los datos del objeto en un diccionario genérico que el script MiniJSON sabe cómo serializar en una cadena JSON.

Cree y adjunte la siguiente secuencia de comandos a la cámara y luego conecte el cubo como referencia para el campo de monstruos:


#region Librerias
using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
#endregion

namespace MoonAntonio
{
	public class Monster : MonoBehaviour 
	{
		public int HP;

		public virtual Dictionary<string, object> Save()
		{
			Dictionary<string, object> data = new Dictionary<string, object>();
			data.Add("HP", HP);
			data.Add("X", transform.localPosition.x);
			data.Add("Y", transform.localPosition.y);
			data.Add("Z", transform.localPosition.z);
			data.Add("Prefab", gameObject.name);
			return data;
		}

		public virtual void Load(Dictionary<string, object> data)
		{
			HP = Convert.ToInt32(data["HP"]);
			float x = Convert.ToSingle(data["X"]);
			float y = Convert.ToSingle(data["Y"]);
			float z = Convert.ToSingle(data["Z"]);
			transform.localPosition = new Vector3(x, y, z);
		}
	}
}

Este script usa la GUI antigua como antes, para mover el monstruo, guardar sus datos o Cargar sus datos. Utilizo el script MiniJSON que descargué para la serialización, y luego guardo el resultado en PlayerPrefs para mayor persistencia.

Ejecuta la escena. Haga clic en Random. En algún momento, haga clic en Guardar y luego click en random algunas veces más. Ahora haz clic en Cargar y ve que el monstruo regresa a la ubicación en la que estaba cuando pulsastes Guardar. Detener la escena y luego ejecutarla de nuevo. ¡El monstruo aún debería estar en el lugar donde lo guardaste!

Aunque técnicamente sabes todo lo que necesitas en este momento, algunos problemas más pueden no ser obvios. Por ejemplo, ¿qué sucede si tiene una lista de objetos que desea guardar? ¿Necesitas crear un nuevo jugador pref para cada uno? ¿Qué pasa si quieres usar el polimorfismo o crear objetos dinámicamente y persistir? Estos son requisitos arquitectónicos muy normales, así que vamos a manejarlos a continuación.

Para nuestro primer paso, necesitaremos modificar el script de Monster para que los métodos de Guardar y Cargar estén marcados como “virtuales”. De esta forma, no es necesario volver a escribir completamente el código de persistencia en cada subclase; en su lugar, solo tenemos que override el método base y anexar los datos nuevos que proporcione la clase secundaria.

Decidí crear tres subclases de Monster: “BlueMonster”, “RedMonster” y “GreenMonster”. Lo sé, nombres de clase muy imaginativos ¿no? El monstruo azul se ve a continuación:


#region Librerias
using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
#endregion

namespace MoonAntonio
{
	public class BlueMonster : Monster 
	{
		public int water;

		void Awake()
		{
			water = UnityEngine.Random.Range(10, 100);
		}

		public override Dictionary<string, object> Save()
		{
			Dictionary<string, object> data = base.Save();
			data.Add("Water", water);
			return data;
		}

		public override void Load(Dictionary<string, object> data)
		{
			base.Load(data);
			water = Convert.ToInt32(data["Water"]);
		}
	}
}

Los monstruos rojos y verdes tienen implementaciones idénticas, excepto que el nombré a la variable local es “fire” y “earth” en vez de water, respectivamente. El objetivo aquí es ilustrar que no se puede usar la misma serialización en los tres porque tienen conjuntos de datos “diferentes”. Sin embargo, debido a que comparten una clase base común, puedo tratarlos de todos modos y ser capaz de guardarlos y cargarlos sin preocuparse por sus diferencias.

Para ayudar a enfatizar las diferencias entre nuestros monstruos, crea tres prefabricados diferentes en tu proyecto, uno para cada monstruo. Puede hacer una con una esfera y otra con un cubo, pero como mínimo colorearlas todas según su nombre para ayudar a demostrar que hay una diferencia. Asegúrese y asigne el script de subclase específico para cada tipo de monstruo y no la versión de clase base de sí mismo.

Ahora tenemos que modificar la secuencia de comandos Demo para nuestra nueva funcionalidad:


#region Librerias
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using MiniJSON;
#endregion

namespace MoonAntonio
{
	public class DemoControllerSerializacion : MonoBehaviour 
	{
		[SerializeField] GameObject[] prefabs;
		Dictionary<string, GameObject> mapping = new Dictionary<string, GameObject>();
		List<Monster> monsters = new List<Monster>();

		void Start()
		{
			for (int i = 0; i < prefabs.Length; ++i)
				mapping.Add(prefabs[i].name, prefabs[i]);
			Load();
		}

		void OnGUI()
		{
			if (GUI.Button(new Rect(10, 10, 100, 30), "Agregar"))
				AddRandom();

			if (GUI.Button(new Rect(10, 50, 100, 30), "Guardar"))
				Save();

			if (GUI.Button(new Rect(10, 90, 100, 30), "Cargar"))
				Load();
		}

		void Randomize(Monster monster)
		{
			monster.HP = UnityEngine.Random.Range(10, 100);
			float x = UnityEngine.Random.Range(-10f, 10f);
			float y = UnityEngine.Random.Range(-10f, 10f);
			float z = UnityEngine.Random.Range(-10f, 10f);
			monster.transform.localPosition = new Vector3(x, y, z);
		}

		void AddRandom()
		{
			GameObject prefab = prefabs[UnityEngine.Random.Range(0, prefabs.Length)];
			Monster monster = Add(prefab);
			Randomize(monster);
		}

		Monster Add(GameObject prefab)
		{
			GameObject instance = Instantiate(prefab) as GameObject;
			Monster monster = instance.GetComponent<Monster>();
			monster.name = prefab.name;
			monsters.Add(monster);
			return monster;
		}

		void Save()
		{
			var monsterData = new List<Dictionary<string, object>>(monsters.Count);
			for (int i = 0; i < monsters.Count; ++i)
				monsterData.Add(monsters[i].Save());

			string json = Json.Serialize(monsterData);
			Debug.Log(json);
			PlayerPrefs.SetString("Monsters", json);
		}

		void Load()
		{
			Clear();
			string json = PlayerPrefs.GetString("Monsters", string.Empty);
			if (!string.IsNullOrEmpty(json))
			{
				var monsterData = Json.Deserialize(json) as List<object>;
				for (int i = 0; i < monsterData.Count; ++i)
				{
					Dictionary<string, object> data = monsterData[i] as Dictionary<string, object>;
					string prefab = (string)(data["Prefab"]);
					Monster monster = Add(mapping[prefab]);
					monster.Load(data);
				}
			}
		}

		void Clear()
		{
			for (int i = monsters.Count - 1; i >= 0; --i)
				Destroy(monsters[i].gameObject);
			monsters.Clear();
		}
	}
}

En esta versión, ya no necesito la referencia al único objeto Monster. Dado que estamos creando dinámicamente desde una variedad de tipos de monstruos, necesito referencias a los prefabricados que podemos crear una instancia (línea 8). Estos podrían haberse obtenido a través de Resources.Load, pero esta versión es aceptable por ahora. No te olvides de asignarlos en el editor.

A continuación, creé un diccionario para mapear desde un nombre prefabricado al propio prefabricado (línea 9): esa configuración se produce en el método de inicio (líneas 14-15). Este paso puede parecer un poco redundante, pero encuentro que el código es un poco más legible (sin mencionar más eficiente) que el código que necesita buscar una coincidencia en el array (muchas comparaciones de cadenas = LENTO).

Solo modifiqué ligeramente el código OnGUI. Reemplacé el botón que movía el monstruo original, con un botón para crear nuevos monstruos dinámicos que ya se moverán.

El método “Randomize” ahora requiere que se le pase un parámetro de Monster, ya que queremos poder mover cualquiera de nuestros monstruos con el mismo código.

El método “AddRandom” es nuevo y elige uno de los prefabricados al azar para crear. Luego se asegura de que el monstruo recién creado se aleatorice con el método mencionado anteriormente.

El método “Add” toma un prefabricado como parámetro, desde el cual instanciará un nuevo monstruo. El script de monstruo almacena una referencia al nombre del prefab que se utilizó, por lo que podemos usar el mismo prefab de nuevo más tarde cuando necesitamos persistir realmente en el objeto. Después de instanciar el monstruo, obtenemos una referencia al script de monstruo (tenga en cuenta que GetComponent funcionará porque nuestras versiones de Subclass de Monster todavía son componentes de Monster) y agrega el componente a una lista para que podamos seguir y administrar todo lo que tenemos creado.

El método “Save” es similar a la versión anterior, pero ahora estamos creando una lista de objetos de diccionario, en lugar de un solo diccionario. Nuestro serializador JSON puede serializar todo el conjunto como una cadena, por lo que podemos pegarlo fácilmente en un PlayerPref como lo hicimos antes. Tenga en cuenta que utilicé una clave pluralizada esta vez para mayor claridad.

El método “Load” cambió un poco más, pero no está mal. Primero destruyo cualquier monstruo existente antes de crear uno nuevo de acuerdo con los datos de Guardar. Tenga en cuenta que esto es más fácil que un sistema Queue, pero una cola de objetos reutilizables es más ideal en el código de producción. La otra gran diferencia (además de trabajar con una lista de diccionarios) es que obtengo una referencia al nombre prefabricado dentro de cada entrada y crea una instancia de un nuevo objeto en consecuencia. Luego cargo los datos correspondientes en el objeto engendrado. Esto es importante porque un Monstruo Rojo no se cargaría correctamente si se pasara los datos de un Monstruo Azul y viceversa.

Siéntete libre de correr la escena y probarlo todo. Agregue un montón de monstruos, guarde los datos (tenga en cuenta que también registro una versión de la salida JSON en la consola en caso de que quiera inspeccionarlo) y luego intente detener e iniciar una nueva sesión. ¡Todos tus monstruos dinámicamente creados vuelven a cargar bien!

Archivos

Si está guardando muchos datos, o si tiene sentido dividirlos en trozos más pequeños que se pueden cargar en diferentes momentos, entonces puede tener sentido escribir en archivos en lugar de poner demasiado en PlayerPrefs.

También usaremos el espacio de nombres System.IO.

Usando el mismo ejemplo anterior, puede modificar solo los métodos Guardar y Cargar de la siguiente manera:


#region Librerias
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using MiniJSON;
using System.IO;
#endregion

namespace MoonAntonio
{
	public class Archivo : MonoBehaviour 
	{
		[SerializeField] GameObject[] prefabs;
		Dictionary<string, GameObject> mapping = new Dictionary<string, GameObject>();
		List<Monster> monsters = new List<Monster>();

		void Start()
		{
			for (int i = 0; i < prefabs.Length; ++i)
				mapping.Add(prefabs[i].name, prefabs[i]);
			Load();
		}

		void OnGUI()
		{
			if (GUI.Button(new Rect(10, 10, 100, 30), "Agregar"))
				AddRandom();

			if (GUI.Button(new Rect(10, 50, 100, 30), "Guardar"))
				Save();

			if (GUI.Button(new Rect(10, 90, 100, 30), "Cargar"))
				Load();
		}

		void Randomize(Monster monster)
		{
			monster.HP = UnityEngine.Random.Range(10, 100);
			float x = UnityEngine.Random.Range(-10f, 10f);
			float y = UnityEngine.Random.Range(-10f, 10f);
			float z = UnityEngine.Random.Range(-10f, 10f);
			monster.transform.localPosition = new Vector3(x, y, z);
		}

		void AddRandom()
		{
			GameObject prefab = prefabs[UnityEngine.Random.Range(0, prefabs.Length)];
			Monster monster = Add(prefab);
			Randomize(monster);
		}

		Monster Add(GameObject prefab)
		{
			GameObject instance = Instantiate(prefab) as GameObject;
			Monster monster = instance.GetComponent<Monster>();
			monster.name = prefab.name;
			monsters.Add(monster);
			return monster;
		}

		void Save()
		{
			var monsterData = new List<Dictionary<string, object>>(monsters.Count);
			for (int i = 0; i < monsters.Count; ++i)
				monsterData.Add(monsters[i].Save());

			string json = Json.Serialize(monsterData);
			string filePath = Application.persistentDataPath + "/Monsters.txt";
			File.WriteAllText(filePath, json);
		}

		void Load()
		{
			Clear();
			string filePath = Application.persistentDataPath + "/Monsters.txt";
			if (File.Exists(filePath))
			{
				string json = File.ReadAllText(filePath);
				var monsterData = Json.Deserialize(json) as List<object>;
				for (int i = 0; i < monsterData.Count; ++i)
				{
					Dictionary<string, object> data = monsterData[i] as Dictionary<string, object>;
					string prefab = (string)(data["Prefab"]);
					Monster monster = Add(mapping[prefab]);
					monster.Load(data);
				}
			}
		}

		void Clear()
		{
			for (int i = monsters.Count - 1; i >= 0; --i)
				Destroy(monsters[i].gameObject);
			monsters.Clear();
		}
	}
}

/img/codebackdoor/learncsharpunity/10.gif
.
Resumen
En esta lección cubrimos algunos métodos de persistencia de datos. Primero cubrimos la persistencia temporal a través de clases estáticas, el patrón de diseño de Singleton y Unity GameObjects que puede sobrevivir a los cambios de escena. Luego exploramos cómo pueden persistir los datos incluso en múltiples sesiones de juego a través de PlayerPrefs, serialización JSON y escritura de datos en archivos. Incluso conseguimos un poco de fantasía al insistir en una lista de objetos polimórficos creados dinámicamente.
Siguiente - Enums y Flags
/img/ref.png
.