Aprende C# con Unity - Scriptable Objects

Los Objetos Scriptables son un tipo especial de objeto de datos en Unity. Tienen varios beneficios importantes, pero es posible que no funcionen de manera ideal para cada situación. En esta lección, cubriremos lo que son y cómo usarlos.

GitHub Gitlab

Introducción a los Objetos Scriptables

Puede pensar en un objeto programable como un objeto destinado solo a contener datos. Si ha estado utilizando clases o estructuras tradicionales de C# para objetos simples solo de datos, podría utilizarlas en su lugar. Por supuesto, usted se estará preguntando “por qué” le gustaría usar un Objeto Scriptable. Aquí hay algunos pros y contras:

Pros

  1. Pueden sobrevivir a una recarga de escena (como cuando construyes tus scripts o cuando ingresas y sales del modo de reproducción).
  2. Se guardan por referencia, mientras que las clases y estructuras normales se serializan como copias completas. Esto puede ayudarlo a evitar la duplicación de datos.
  3. Pueden manejar el polimorfismo, mientras que las clases normales terminan siendo tratadas como la clase base.
  4. Se pueden guardar como un asset del proyecto.
  5. No necesitan estar adjuntos a GameObjects.

Contras

  1. Debe heredar de ScriptableObject, lo que puede romper muchas de sus opciones arquitectónicas o de diseño.
  2. No puede crearlos usando constructores normales, sino que debe usar “CreateInstance” en su lugar.
  3. Los beneficios de serialización no son igualmente aplicables en tiempo de ejecución.

He creado varias mini ejemplos para aclarar estos puntos. Las dos primeras demostraciones muestran cómo podría encontrarse con problemas si no estuviera usando objetos. Las dos demos siguientes muestran cómo ScriptableObjects supera esos mismos problemas.

Ejemplo 1 :: Pérdida de referencias de objeto en la serialización

Comencemos con algunos ejemplos de serialización. Comience por crear un nuevo script llamado “Demo1” y otro llamado “Demo1Data”, también agregue un script de editor llamado “Demo1Inspector”:


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class Demo1 : MonoBehaviour 
	{
		public Demo1Data dataA;
		public Demo1Data dataB;
	}
}

Este script tendrá dos copias de la misma instancia de “Demo1Data”. Usaremos un script editor para crear y asignar sus valores.


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	[System.Serializable]
	public class Demo1Data
	{
		public int value;
	}
}

Este script muestra una clase C# estándar muy simple. Se puede serializar, gracias a la etiqueta “[System.Serializable]”, pero Unity no lo manejará perfectamente, lo que se demostrará pronto.


#region Librerias
using UnityEngine;
using UnityEditor;
#endregion

namespace MoonAntonio
{
	[CustomEditor(typeof(Demo1))]
	public class Demo1Inspector : Editor
	{
		public override void OnInspectorGUI()
		{
			DrawDefaultInspector();
			Demo1 myTarget = (Demo1)target;
			if (GUILayout.Button("Crear Data"))
			{
				myTarget.dataA = new Demo1Data();
				myTarget.dataB = myTarget.dataA;
			}
		}
	}
}

Este script proporcionará un botón en el inspector de nuestro componente que creará una nueva instancia de “Demo1Data” y la asignará a ambos campos en el script “Demo1”. IMPORTANTE : el script del editor debe agregarse a una carpeta “Editor” o no funcionará correctamente.

Adelante y crea una nueva escena. Agregue la “Demo1” como componente a cualquier objeto, crear un nuevo objeto de juego vacío o incluso adjuntarlo a la cámara. Luego mira en el inspector. Unity creará automáticamente nuevas instancias de “Demo1Data” para ambos campos simplemente mirando el objeto en el inspector. Puede asignar cualquier valor que desee a cada uno de los campos “value”. Si ingresa y sale del modo de reproducción, los valores incluso persistirán, hasta ahora todo bien.

Salga del modo de reproducción (si aún no lo hizo), luego use el botón “Crear data” en el inspector. El valor para ambos campos debería volver a ‘0’ porque ambos campos ahora se refieren a la misma instancia nueva. Si modifica el campo de valor de “dataB”, debería ver el campo de valor de la actualización “dataA” para que coincida en consecuencia. Sigue luciendo bien … al menos hasta que ingrese y salga del modo de reproducción. Pruébalo, luego modifica el valor de “dataB” una vez más. Uh oh, ¡los dos ya no están haciendo referencia al mismo objeto! Unity ha creado una copia completa del objeto original para ambos campos.

Demo 2 :: Pérdida de tipo de objeto en la serialización

Esta demostración mostrará cómo Unity no puede serializar correctamente el tipo de un objeto. Puede encontrar este problema con una lista polimórfica de objetos. Crea lo siguiente:


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class Demo2 : MonoBehaviour 
	{
		public Demo2Data[] dataArray;
	}
}

Este script tendrá una matriz de objetos. Cada objeto compartirá una clase base - “Demo2Data”, pero en realidad se instanciará como una subclase.


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	[System.Serializable]
	public class Demo2Data
	{
		public string name;
		public override string ToString()
		{
			return string.Format("[{0}]", name);
		}
	}

	[System.Serializable]
	public class Demo2NumberData : Demo2Data
	{
		public int number;
		public override string ToString()
		{
			return string.Format("[{0}, {1}]", name, number);
		}
	}

	[System.Serializable]
	public class Demo2BoolData : Demo2Data
	{
		public bool toggle;
		public override string ToString()
		{
			return string.Format("[{0}, {1}]", name, toggle);
		}
	}
}

Aquí hay tres clases, una clase base llamada “Demo2Data” y dos subclases de la misma. Tenga en cuenta que nunca instanciaremos una copia de la clase base directamente.


#region Librerias
using UnityEngine;
using UnityEditor;
#endregion

namespace MoonAntonio
{
	[CustomEditor(typeof(Demo2))]
	public class Demo2Inspector : Editor
	{
		public override void OnInspectorGUI()
		{
			DrawDefaultInspector();
			Demo2 myTarget = (Demo2)target;
			if (GUILayout.Button("Crear Data"))
			{
				var dataA = new Demo2NumberData();
				dataA.name = "Demo2NumberData";
				dataA.number = UnityEngine.Random.Range(1, 100);
				var dataB = new Demo2BoolData();
				dataB.name = "Demo2BoolData";
				dataB.toggle = UnityEngine.Random.value > 0.5;
				myTarget.dataArray = new Demo2Data[] { dataA, dataB };
			}
			if (GUILayout.Button("Log"))
			{
				foreach (var data in myTarget.dataArray)
				{
					Debug.Log(data.ToString());
				}
			}
		}
	}
}

Este script proporcionará algunos botones en el inspector de nuestro componente. El primero está etiquetado como “Crear data” e instanciará cada una de nuestras subclases de datos y las asignará a la matriz de datos de nuestro script. El segundo botón está etiquetado como “Log” y hará que cada objeto en la matriz imprima sus valores en la ventana de la consola. IMPORTANTE : el script del editor debe agregarse a una carpeta “Editor” o no funcionará correctamente.

Adelante y crea una nueva escena. Agregue la “Demo2” como un componente a cualquier objeto del juego, como crear un nuevo objeto de juego vacío o incluso adjuntarlo a la cámara. Luego mira en el inspector. Unity creará automáticamente una matriz vacía de datos simplemente mirando el objeto en el inspector. Llenemos nuestro objeto con datos haciendo clic en el botón “Crear datos”. Debería ver que la matriz contiene ahora dos objetos.

Aunque la clase de datos base y sus subclases tienen la etiqueta “[System.Serializable]”, no verá los campos agregados para los campos “number” o “toggle” de las instancias reales. Esto se debe a que Unity los trata como a la clase base, que solo conoce el “nombre” del objeto. Sin embargo, los datos todavía están allí (al menos por el momento). Haga clic en el botón “Log” y debería ver la descripción completa. En una de mis propias ejecuciones vi salidas como las siguientes:

[Demo2NumberData, 84]

[Demo2BoolData, False]

Se ve bien hasta ahora, ¿verdad? Bueno, veamos si puede sobrevivir a una recarga de escena. Adelante, ingrese y salga del modo de reproducción. Ahora presione el botón “Log” una vez más. Debería ver un resultado como este:

[Demo2NumberData]

[Demo2BoolData]

Al igual que Unity no sabía cómo mostrar los objetos correctamente, ¡tampoco sabía cómo serializarlos correctamente! ¡Ambos objetos ahora son instancias de la clase base y se pierden sus datos de subclase!

Demo 3 :: Las referencias a objetos Scriptable sobreviven a la serialización

Esta vez recrearemos la Demo 1, excepto que usaremos un Objeto Scriptable para nuestros datos serializados en lugar de una clase C# estándar. Crea lo siguiente:


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class Demo3 : MonoBehaviour 
	{
		public Demo3Data dataA;
		public Demo3Data dataB;
	}
}


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	[System.Serializable]
	public class Demo3Data : ScriptableObject
	{
		public int value;
	}
}


#region Librerias
using UnityEngine;
using UnityEditor;
#endregion

namespace MoonAntonio
{
	[CustomEditor(typeof(Demo3))]
	public class Demo3Inspector : Editor
	{
		public override void OnInspectorGUI()
		{
			DrawDefaultInspector();
			Demo3 myTarget = (Demo3)target;
			if (GUILayout.Button("Crear Data"))
			{
				myTarget.dataA = ScriptableObject.CreateInstance<Demo3Data>();
				myTarget.dataB = myTarget.dataA;
			}
		}
	}
}

Crea una nueva escena y adjunta el script Demo3 a un objeto. A diferencia de Demo1, el script Demo3 no creará automáticamente nuevas instancias del objeto Scriptable con solo mirar el script en el inspector. Para comenzar a jugar con datos, haga clic en el botón “Crear data”. Ahora, ambos campos muestran el objeto de datos en si mismo. Podríamos personalizar aún más el script del editor para que se vea similar a Demo1 si así lo desea, pero por ahora no es necesario. Para editar el valor del objeto compartido, haga doble clic en el objeto de datos en cualquier campo. La ventana del inspector se actualizará mostrando solo el objeto que está editando.

Ahora, para la gran prueba, ¿puede esta versión sobrevivir a una recarga de escena? Continúa e ingresa y luego sal del modo de reproducción. Intente editar el valor de cualquier objeto de datos. Luego regrese y abra el objeto a través del otro campo. ¡Debería ver que la referencia se serializó correctamente, porque tendrá el mismo valor! Unity pudo retener la referencia compartida en lugar de tener que serializar una copia completa del objeto para cada campo.

Demo 4 :: El tipo de objeto Scriptable sobrevive a la serialización

Ahora recreemos Demo 2 (la demostración de polimorfismo) al usar objetos programables en lugar de objetos C# estándar. Tenga en cuenta que en la demostración 2, el objeto de datos base y sus subclases compartieron un solo archivo de script. Unity tiene algunos requisitos adicionales tales que cada objeto programable debe aparecer en su propio archivo y el nombre del archivo debe coincidir con el nombre de la clase.


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class Demo4 : MonoBehaviour 
	{
		public Demo4Data[] dataArray;
	}
}


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class Demo4Data : ScriptableObject
	{
		public override string ToString()
		{
			return string.Format("[{0}]", name);
		}
	}
}


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class Demo4NumberData : Demo4Data
	{
		public int number;
		public override string ToString()
		{
			return string.Format("[{0}, {1}]", name, number);
		}
	}
}


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	public class Demo4BoolData : Demo4Data
	{
		public bool toggle;
		public override string ToString()
		{
			return string.Format("[{0}, {1}]", name, toggle);
		}
	}
}


#region Librerias
using UnityEngine;
using UnityEditor;
#endregion

namespace MoonAntonio
{
	[CustomEditor(typeof(Demo4))]
	public class Demo4Inspector : Editor
	{
		public override void OnInspectorGUI()
		{
			DrawDefaultInspector();
			Demo4 myTarget = (Demo4)target;
			if (GUILayout.Button("Crear Data"))
			{
				var dataA = ScriptableObject.CreateInstance<Demo4NumberData>();
				dataA.name = "Demo4NumberData";
				dataA.number = UnityEngine.Random.Range(1, 100);
				var dataB = ScriptableObject.CreateInstance<Demo4BoolData>();
				dataB.name = "Demo4BoolData";
				dataB.toggle = UnityEngine.Random.value > 0.5;
				myTarget.dataArray = new Demo4Data[] { dataA, dataB };
			}
			if (GUILayout.Button("Log"))
			{
				foreach (var data in myTarget.dataArray)
				{
					Debug.Log(data.ToString());
				}
			}
		}
	}
}

Continúa y crea una nueva escena, luego adjunta el script Demo4 a cualquier objeto. Utilice el script del inspector para “Crear datos” en nuestro componente Demo4. Al igual que en Demo3, debe hacer doble clic en el campo del objeto para ver y editar los valores de cada instancia del objeto. Use el botón “Log” para ver rápidamente cada ventana impresa en la consola.

Ahora, para la gran prueba, ¿puede esta versión sobrevivir a una recarga de escena? Continúa e ingresa y luego sal del modo de reproducción. Presione el botón “Log” una vez más. ¡Éxito!

Demo 5 :: Assets Scriptable Object

Dije que podías guardar estos objetos como assets, y también mencioné repetidamente que los Objetos Scriptables no se adjuntan a GameObjects, sin embargo, en cada demostración hasta el momento solo los he mostrado como referencias en scripts de MonoBehaviour. En esta lección, finalmente mostraré cómo trabajar con estos objetos de datos por su cuenta.

Solía ​​ser un proceso más engorroso para crear Objetos Scriptables, pero ahora tenemos una etiqueta llamada “CreateAssetMenu” que lo maneja automáticamente para nosotros. Puedes comenzar con algo tan simple como:


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	[CreateAssetMenu()]
	public class ScriptableTets : ScriptableObject
	{
		public int value;
	}
}

Compila tu código y regresa a Unity. Puede usar la barra de menú de la aplicación (Assets -> Create -> ScriptableTets), o el menú desplegable “Create” del panel Proyecto (Create -> ScriptableTets). Seleccione uno y se creará un nuevo activo en su proyecto llamado “New ScriptableTets”. Puede cambiar el nombre del activo, moverlo a otra carpeta, rellenarlo con los datos personalizados, etc. Guarde el proyecto y ahora tiene una aplicación práctica del asset.

El “CreateAssetMenu” también puede tomar parámetros. En la siguiente versión, especifico el nombre de las instancias recientemente creadas, hago que aparezca en un submenú y especifico un orden para que pueda hacer que los objetos utilizados con mayor frecuencia aparezcan en la parte superior de la lista.


[CreateAssetMenu(fileName = "ScriptableTets", menuName = "Scriptable Objects/ScriptableTets", order = 1)]

Si ha revisado la documentación del  Scriptable Object , puede haber notado que tiene algunos métodos similares en nombre a aquellos en un MonoBehaviour. Por ejemplo, tiene: Awake, OnDestroy, OnDisable y OnEnable. Como no hay GameObject, ¿cuándo se llaman? Desafortunadamente, la respuesta probablemente no sea la esperada. Agregué mensajes de registro de depuración a cada uno de estos métodos en mi clase “ScriptableTets”.


#region Librerias
using UnityEngine;
#endregion

namespace MoonAntonio
{
	[CreateAssetMenu(fileName = "ScriptableTets", menuName = "Scriptable Objects/ScriptableTets", order = 1)]
	public class ScriptableTets : ScriptableObject
	{
		public int value;

		void Awake()
		{
			Debug.Log("Awake ScriptableTets " + name);
		}

		void OnDestroy()
		{
			Debug.Log("Destroy ScriptableTets " + name);
		}

		void OnEnable()
		{
			Debug.Log("OnEnable ScriptableTets " + name);
		}

		void OnDisable()
		{
			Debug.Log("OnDisable ScriptableTets " + name);
		}
	}
}

  1. Compila tus scripts. Ahora crea un nuevo assets “ScriptableTets”. Debería ver que se llama a “Awake” y luego a “OnEnable”, en ese orden. Esto probablemente se esperaba, si está familiarizado con el pedido de MonoBehaviour.
  2. Haga clic en el activo ScriptableTets para que su nombre se aplique y deje de estar seleccionado.
  3. Luego, ingrese el modo de reproducción. Debería ver que se llama “OnDisable”, luego se llama a “OnEnable” una vez más. Esto tiene que ver con la forma en que los objetos pasan entre el motor central C++ de Unity y el lado de scripting C# de Unity.
  4. Salga del modo de reproducción y verá nuevamente “OnDisable”, pero en realidad no verá una llamada a “OnEnable” como podría haber esperado.
  5. Si ahora selecciona el activo “ScriptableTets” para que aparezca en el inspector, verá una llamada “Awake” y otra vez “OnEnable”.

Sin haberlo probado, habría pensado que estos métodos estaban pensados ​​para el uso en tiempo de ejecución y no serían invocados por las acciones del editor. Además, habría pensado que “Awake” estaría reservado para la creación del activo solamente, especialmente dado que no se nos permite usar el constructor de un Objeto Scriptable. En mi opinión, realmente debería haber algún tipo de método “init” que solo se llame una vez para la creación del activo. Aquí es donde normalmente agregaría trabajo de configuración para un objeto que no quisiera que ocurriera más de una vez. Para obtener el comportamiento que deseo, aún puedo usar un script editor para crear y configurar manualmente mi activo.

Objetos Scriptable en tiempo de ejecución

He mostrado los objetos de script utilizados tanto en el modo de edición como durante el modo de reproducción. Sin embargo, vale la pena señalar que algunos de los mayores beneficios de los objetos programables, en particular su fácil serialización, no es algo que pueda utilizar en tiempo de ejecución.

Aún puede guardar datos, como mediante el uso de JsonUtility para convertir sus objetos programables en JSON. El resultado podría guardarse de varias maneras, por ejemplo, escribiendo el valor en PlayerPrefs o escribiendo un archivo en el disco. Desafortunadamente, es probable que termine con los mismos desafíos de serialización demostrados en mis dos primeras demos. No tendrá una manera fácil de conservar referencias de objeto, ni tendrá una manera fácil de volver a crear matrices de objetos polimórficos.

Resumen
Los Objetos Scriptables son excelentes pequeños contenedores de datos. Se pueden usar en tiempo de ejecución o editar e incluso se pueden guardar como recursos del proyecto. Ofrecen varios beneficios que los objetos estándar y las clases pierden, como la correcta serialización, pero no son perfectos. Creo que su interfaz podría ser más intuitiva, y personalmente no me gusta la restricción de tener que heredar de ScriptableObject o de no poder usar un constructor estándar. En general, vale la pena dedicarles un poco de tiempo porque pueden proporcionar algunos flujos de trabajo convenientes y pueden ayudarlo a prototipar rápidamente su contenido.

/img/ref.png
.