Hola a todos, mi nombre es Dmitrii Ivashchenko y soy ingeniero de software en MY.GAMES. En este artículo, hablaremos sobre el desarrollo de una interfaz de usuario en Unity basada en estados y marcado de elementos.
En primer lugar, debe tenerse en cuenta que hablaremos en el contexto de la tecnología Unity UI (uGUI), que aún se recomienda para Runtime según la documentación . El enfoque descrito no se aplica a UI Toolkit , IMGUI u otros sistemas de creación de UI.
La mayoría de las veces, en los proyectos de Unity , se encontrará con la implementación de la interfaz de usuario basada en las clases View heredadas de MonoBehaviour
y salpicada con una gran cantidad de campos SerializeField
. Este enfoque proporciona un control total sobre el comportamiento de la interfaz de usuario, pero también hace que sea necesario escribir una gran cantidad de código en los niveles de Vista y Presentador (según la arquitectura utilizada).
A menudo, a medida que continúa el desarrollo del proyecto, estas clases aumentan a tamaños increíbles, y los componentes de GameObject en sí mismos están cubiertos con una gran cantidad de enlaces a objetos internos:
Modificar componentes como este tampoco es divertido: para obtener una referencia a un nuevo elemento en una clase, debe agregar SerializeField
, volver a compilar el código, encontrar el nuevo campo en el componente prefabricado y arrastrar el objeto necesario hacia él. A medida que crece el proyecto, el tiempo de compilación, la cantidad de campos y la complejidad de organizar prefabricados también aumentan a su vez.
Como resultado, terminamos con subclases voluminosas y sobrecargadas de MonoBehaviour
(o una gran cantidad de subclases pequeñas, según sus preferencias).
También vale la pena considerar que cualquier cambio en el comportamiento de dicha interfaz de usuario es una tarea para el programador, y esa tarea conlleva todos los costos asociados: revisión de código, resolución de conflictos de fusión, cobertura de código con pruebas, etc.
SerializeField
correspondiente, y luego se escribe una gran cantidad de código para que funcione de acuerdo con los requisitos. . Naturalmente, solo un programador puede manejar esto, y la implementación resulta ser larga, costosa y súper eficiente (a menudo mucho más eficiente de lo que nadie puede notar).
Ahora que hemos resaltado algunas de las dificultades de trabajar con uGUI, me gustaría hablar sobre un enfoque diferente para resolver este problema.
Durante mi trabajo en uno de mis proyectos favoritos, desarrollé una biblioteca para el desarrollo de una interfaz de usuario estructurada en Unity. Más tarde, mi equipo y yo lo probamos en producción y quedamos satisfechos con los resultados.
El código fuente de la biblioteca está disponible para su descarga en GitHub .
El elemento clave de la biblioteca es el componente StatefulComponent
. Este componente se ubica en el GameObject raíz de cada pantalla y contiene todas las referencias necesarias a los elementos internos, distribuidas en pestañas:
Cada enlace recibe un nombre en función de su función . Desde la perspectiva del código, el conjunto de roles es una enum
regular. Se preparan conjuntos separados de roles para cada tipo de elemento de la interfaz de usuario (botones, imágenes, textos, etc.):
public enum ButtonRole { ... } public enum ImageRole { ... } public enum TextRole { ... } ...
Los roles se generan directamente desde el componente y no es necesario editar manualmente la enum
. Tampoco es necesario esperar la recompilación al crear un rol, ya que estos elementos enum
se pueden usar inmediatamente después de la creación.
Para simplificar los conflictos de combinación, los valores de enumeración se calculan en función de los nombres de los elementos:
[StatefulUI.Runtime.RoleAttributes.ButtonRoleAttribute] public enum ButtonRole { Unknown = 0, Start = -1436209294, // -1436209294 == "Start".GetHashCode() Settings = 681682073, Close = -1261564850, Quests = 1375430261, }
Esto le permite evitar romper valores serializados en prefabricados si usted y sus colegas crean simultáneamente nuevos roles para botones en diferentes ramas.
Cada tipo de elemento de la interfaz de usuario (botones, textos, imágenes) se encuentra en su propia pestaña:
Mediante el uso de roles, se logra el marcado completo de todos los elementos dentro de la casa prefabricada. Ya no se necesitan conjuntos de SerializeField
para acceder a imágenes y textos, y basta con tener una referencia a StatefulComponent
y saber el rol de la imagen deseada para, por ejemplo, reemplazar su sprite.
UnityEngine.UI.Text
y TextMeshProUGUI
UnityEngine.UI.InputField
y TMP_InputField
Existen métodos correspondientes para trabajar con objetos anotados. En el código, puede usar una referencia a StatefulComponent
o heredar la clase de StatefulView
:
public class ExamplePresenter { private StatefulComponent _view; public void OnOpen() { _view.GetButton(ButtonRole.Settings).onClick.AddListener(OnSettingsClicked); _view.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); _view.GetSlider(SliderRole.Volume).onValueChanged.AddListener(OnVolumeChanged); } } public class ExampleScreen : StatefulView { private void Start() { SetText(TextRole.Title, "Hello World"); SetTextValues(TextRole.Timer, hours, minutes, seconds); SetImage(ImageRole.UserAvatar, avatarSprite); } }
La pestaña con textos, además del rol y enlace al objeto, contiene las siguientes columnas:
La biblioteca no incluye un subsistema incorporado para trabajar con traducciones. Para conectar su sistema de localización, deberá crear una implementación de la interfaz ILocalizationProvider
. Esto se puede construir, por ejemplo, en función de su back-end, ScriptableObjects o Google Sheets.
public class HardcodeLocalizationProvider : ILocalizationProvider { private Dictionary<string, string> _dictionary = new Dictionary<string, string> { { "timer" , "{0}h {1}m {2}s" }, { "title" , "Título do Jogo" }, { "description" , "Descrição longa do jogo" }, }; public string GetPhrase(string key, string defaultValue) { return _dictionary.TryGetValue(key, out var value) ? value : defaultValue; } }
Al hacer clic en el botón Copiar localización, el contenido de las columnas Código y Valor se copiará en el portapapeles en un formato adecuado para pegarlo en Hojas de cálculo de Google.
A menudo, para facilitar la reutilización, se extraen partes separadas de la interfaz de usuario en prefabricados separados. StatefulComponent
también nos permite crear una jerarquía de componentes, donde cada componente solo funciona con sus propios elementos de interfaz secundarios.
En la pestaña Componentes internos, puede asignar funciones a los componentes internos:
Los roles configurados se pueden usar en el código de manera similar a otros tipos de elementos:
var header = GetInnerComponent(InnerComponentRole.Header); header.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); header.SetText(TextRole.Title, "Header Title"); var footer = GetInnerComponent(InnerComponentRole.Footer); footer.GetButton(ButtonRole.Continue).onClick.AddListener(OnContinueClicked); footer.SetText(TextRole.Message, "Footer Message");
Para crear una lista de elementos similares, puede utilizar el componente ContainerView
. Debe especificar el prefabricado para la creación de instancias y el objeto raíz (opcional). En el modo de edición, puede agregar y eliminar elementos usando StatefulComponent
:
Es conveniente usar StatefulComponent
para marcar el contenido de los prefabricados instanciados. En Runtime, puede usar los métodos AddInstance<T>
, AddStatefulComponent
o FillWithItems
para llenar el contenedor:
var container = GetContainer(ContainerRole.Players); container.Clear(); container.FillWithItems(_player, (StatefulComponent view, PlayerData data) => { view.SetText(TextRole.Name, data.Name); view.SetText(TextRole.Level, data.Level); view.SetImage(ImageRole.Avatar, data.Avatar); });
Si el Object.Instantiate()
estándar no le conviene para crear objetos, puede anular este comportamiento, por ejemplo, para la creación de instancias con Zenject:
StatefulUiManager.Instance.CustomInstantiateMethod = prefab => { return _diContainer.InstantiatePrefab(prefab); };
Los componentes internos y los contenedores proporcionan anidamiento estático y dinámico para StatefulComponent
, respectivamente.
Hemos considerado el marcado de los prefabricados, la localización y la creación de instancias. Ahora es el momento de pasar a la parte más interesante: desarrollar interfaces de usuario basadas en estados.
Consideraremos el concepto de estado como un conjunto de cambios con nombre en una casa prefabricada. El nombre en este caso es un rol de la enumeración StateRole
, y los ejemplos de cambios en el prefabricado pueden ser:
Se puede configurar un conjunto de cambios (Descripción del estado) en la pestaña Estados. Un estado configurado se puede aplicar directamente desde el inspector:
Se puede aplicar un estado configurado desde el código usando el método SetState
:
switch (colorScheme) { case ColorScheme.Orange: SetState(StateRole.Orange); break; case ColorScheme.Red: SetState(StateRole.Red); break; case ColorScheme.Purple: SetState(StateRole.Purple); break; }
En la pestaña Herramientas, cuando el parámetro Aplicar estado inicial al habilitar está habilitado, puede configurar el estado que se aplicará inmediatamente después de la instanciación del objeto.
El uso de estados permite una reducción significativa en la cantidad de código requerido en el nivel de clase de Vista. Simplemente describa cada estado de su pantalla como un conjunto de cambios en StatefulComponent
y aplique el estado necesario del código según la situación del juego.
En realidad, desarrollar una interfaz de usuario basada en estados es increíblemente conveniente. Tanto es así que, con el tiempo, conduce a otro problema: a medida que el proyecto evoluciona, la lista de estados para una sola ventana puede crecer hasta una longitud ingobernable y, por lo tanto, se vuelve difícil de navegar. Además, hay estados que solo tienen sentido en el contexto de algunos otros estados. Para resolver este problema, Statful UI tiene otra herramienta: State Tree. Puede acceder a él haciendo clic en el botón Editor de árbol de estado en la pestaña Estados.
Los estados principales (en este ejemplo , Recompensa ) se aplican cada vez que se llama a los estados secundarios:
Administrar un StatefulComponent
configurado se reduce a una cantidad mínima de código simple y comprensible que llena los componentes con los datos necesarios y cambia de estado en el momento adecuado:
public void ShowIntro() { SetState(StateRole.Intro); } public void ShowReward(IReward reward) { // Update the inner view with the reward reward.UpdateView(GetInnerComponent(InnerComponentRole.Reward)); // Switch on the type of reward switch (reward) { case ICardsReward cardsReward: SetState(StateRole.Cards); break; case IMoneyReward moneyReward: SetState(StateRole.Money); break; case IEmojiReward emojiReward: SetState(StateRole.Emoji); break; } } public void ShowResults(IEnumerable<IReward> rewards) { SetState(StateRole.Results); // Fill the container with the rewards GetContainer(ContainerRole.TotalReward) .FillWithItems(rewards, (view, reward) => reward.UpdateView(view)); }
Los roles están destinados a proporcionar una forma conveniente e inequívoca de nombrar enlaces y estados para su uso posterior en el código. Sin embargo, hay situaciones en las que describir un estado requeriría un nombre demasiado largo y sería más conveniente dejar un pequeño comentario sobre a qué apunta este enlace o qué comportamiento refleja el estado. Para tales casos, cada enlace y estado en un StatefulComponent
le permite agregar una descripción:
Es posible que ya haya notado los botones Copiar API y Copiar documentos en cada pestaña; estos copian información para la sección seleccionada. Además de esos, hay botones similares en la pestaña Herramientas: estos copian información para todas las secciones a la vez. Al hacer clic en el botón Copiar API, el código generado para administrar este objeto StatfulComponent
se copiará en el portapapeles. Aquí hay un ejemplo para nuestra ventana de recompensas:
// Insert the name of the chest here SetText(TextRole.Title, "Lootbox"); // Button to proceed to the reward issuance phase GetButton(ButtonRole.Overlay); // Button to display information about the card GetButton(ButtonRole.Info); // Container for displaying the complete list of awarded rewards GetContainer(ContainerRole.TotalReward); // Insert the card image here SetImage(ImageRole.Avatar, null); // Animated appearance of a chest SetState(StateRole.Intro);
Al hacer clic en el botón Copiar documentos, la documentación de este prefabricado se copiará en el portapapeles en formato Markdown:
### RewardScreen Buttons: - Overlay - Button to proceed to the reward issuance phase - Info - Button to display information about the card Texts: - Title - Insert the name of the chest here Containers: - TotalReward - Container for displaying the complete list of awarded rewards Images: - Avatar - Insert the card image here States: - Intro - Animated appearance of a chest - Cards - Displaying rewards in the form of a card - Money - Displaying rewards in the form of game currency - Emoji - Displaying rewards in the form of an emoji - Results - Displaying a complete list of issued rewards
Es obvio que cometer un error al implementar esta pantalla con instrucciones tan detalladas es bastante difícil. Puede mantener fácilmente información actualizada sobre su organización de interfaz de usuario en la base de conocimientos del proyecto.
Al mismo tiempo, Stateful UI permite delegar la creación de prefabricados de UI. De hecho, el marcado basado en el estado permite probar completamente el comportamiento del prefabricado antes de pasarlo a los programadores. Esto significa que los diseñadores de juegos , los diseñadores técnicos o incluso los desarrolladores de UI independientes pueden preparar prefabricados. Además, dado que se crea una API entre el código y el prefabricado, ¡la programación y la configuración de los prefabricados se pueden hacer en paralelo! Todo lo que se requiere es formular la API por adelantado. Pero, incluso si la tarea de configurar prefabricados sigue siendo de los programadores, el uso de Stateful UI acelera significativamente este trabajo.
Como hemos visto, Stateful UI simplifica significativamente el trabajo con los estados de los elementos de la UI. Ya no se necesitan ciclos largos para crear SerializeFields, recompilar código y buscar referencias entre una gran cantidad de campos de clase View. En las propias clases de Vista, ya no es necesario escribir una gran cantidad de código para operaciones repetitivas, como encender y apagar objetos o cambiar el color del texto.
La biblioteca permite un enfoque coherente para organizar diseños en un proyecto, marcar objetos dentro de prefabricados, crear estados, vincularlos a elementos de la interfaz de usuario y proporcionar una API y documentación para la gestión de la interfaz de usuario. También permite delegar la creación de prefabricados de interfaz de usuario y acelera el trabajo con ellos.
Expandir las capacidades de los estados, admitir nuevos tipos de cambios en la interfaz de usuario en la descripción, como nuevos tipos de animaciones, reproducir sonidos en los estados, etc.
Agregar soporte para paletas de colores para colorear texto e imágenes
Agregar soporte para listas de elementos con la reutilización de GameObjects
Compatibilidad con una mayor cantidad de elementos de la interfaz de usuario de Unity
Automatización de la descarga de textos añadidos para su localización
Implementación de un marco de prueba. Dado que tenemos un marcado exhaustivo de nuestros prefabricados, podemos crear escenarios basados en ScriptableObject fáciles de configurar en el siguiente formato:
Haga clic en el botón ButtonRole.Settings
Verifique que el texto en TextRole.SomeText
sea igual a "algún valor"
Verifique la imagen en ImageRole.SomeImage
para asegurarse de que sea igual a un cierto sprite
Un sistema tutorial. De manera similar a las pruebas, el diseño marcado permite crear escenarios de tutoriales basados en ScriptableObject en forma de instrucciones como "Mostrar puntero en el botón ButtonRole.UpgradeHero
".
El código fuente del proyecto está disponible en GitHub . ¡Le invitamos a crear problemas o contribuir a la biblioteca!